diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index 1aab31da..5f52226f 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,6 +1,5 @@ name: "Bug Report" description: Report a bug -title: "" labels: [ "bug" ] @@ -23,6 +22,16 @@ body: 3. See error validations: required: false + - type: dropdown + id: experimental + attributes: + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" + validations: + required: true - type: input id: app-version attributes: diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index cbb287b5..389d4e8a 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,6 +1,5 @@ name: "Playback Problem" description: Report an issue with playback -title: "<title>" labels: [ "bug", "playback" ] @@ -12,12 +11,14 @@ body: description: Please enter a description of the playback issue validations: required: true - - type: textarea - id: mediainfo + - type: dropdown + id: experimental 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 + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" validations: required: true - type: dropdown @@ -32,6 +33,14 @@ body: - External App (please specify) validations: required: true + - type: textarea + id: mediainfo + 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: input id: app-version attributes: diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d7b2f290..24711c4a 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,11 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 with: distribution: zulu java-version: '21' @@ -21,9 +21,9 @@ runs: echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV - name: Setup Android SDK - uses: android-actions/setup-android@v4 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 with: - packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" + packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path shell: bash run: | diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6cb20eeb..3bd2e54d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ concurrency: env: BRANCH_NAME: ${{ github.ref_name }} - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: build: @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -54,7 +54,8 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon + ./gradlew clean assembleDefaultDebug + ./gradlew assembleDefaultRelease - name: Verify signatures run: | @@ -76,7 +77,7 @@ jobs: echo "SHA256 checksums:" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum - name: Upload mapping files - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mapping path: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8607abd8..9e518406 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,29 +9,29 @@ defaults: env: BUILD_DIRS_ARTIFACT: build-dirs - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: pre-commit: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Run pre-commit - uses: pre-commit/action@v3.0.1 + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 build: runs-on: ubuntu-latest needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0520630..4822ce7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ defaults: env: APK_SHORT_NAME: Wholphin.apk - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" TAG_NAME: ${{ github.ref_name }} jobs: @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -36,7 +36,8 @@ jobs: ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean bundleAppstoreRelease bundleFiretvRelease + ./gradlew clean bundleAppstoreRelease + ./gradlew bundleFiretvRelease ./gradlew assembleDefaultRelease - name: Verify signatures run: | @@ -58,14 +59,14 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: AAB path: | app/build/outputs/bundle/**/*.aab compression-level: 0 - name: Upload mapping files - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mapping path: | diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a2338108..0d818e21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -42,6 +42,8 @@ If you want to include these in a local build, see the [instructions here](https You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry. +Finally, if no `wholphin-mpv` implementation is found, `wholphin-mpv-stub` will be used. This allows the app to compile, but any runtime usage of MPV will throw an exception. + ## Code organization Code is split into several packages: @@ -95,3 +97,9 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON. This should be used sparingly. + +#### Experimental + +Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time. + +These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose. diff --git a/Intents.md b/Intents.md new file mode 100644 index 00000000..7f01a501 --- /dev/null +++ b/Intents.md @@ -0,0 +1,168 @@ +# Intents + +> [!IMPORTANT] +> This feature is experimental and the exact behaviors and/or parameters may change at any time + +Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media. + +Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`). + +UUID parameters can be specified with or without hyphens. + +If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...` + +## Server/user switching + +You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it. + +If not specified, the app will use the current user if auto sign in is enabled. + +## Search + +Action: `android.intent.action.SEARCH` + +Shorthand action: `search` + +Parameters: +- `query` - Optional, prepopulate the search field + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.SEARCH \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es query "Avengers" +``` + +URI based +```bash +adb shell am start -d 'wholphin://search?query=Avengers' +``` + +Multi-word query: +```bash +# Whole shell command is wrapped in double quotes +adb shell "am start \ + -a android.intent.action.SEARCH \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es query 'Avengers Endgame'" + +adb shell am start -d 'wholphin://search?query=Avengers+Endgame' +adb shell am start -d 'wholphin://search?query=Avengers%20Endgame' +``` + +## View + +This opens the app to the specified item such as an episode or movie + +Action: `android.intent.action.VIEW` + +Shorthand action: `view` + +Parameters: +- `itemId` - Optional, the UUID of the media item, if not set, goes to home screen + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +## Playback + +Action: `com.github.damontecres.wholphin.PLAYBACK` + +Shorthand action: `play` + +Parameters: +- `itemId` - Required, the UUID of the media item +- `position` - Optional, the start position for playback in milliseconds +- `shuffle` - Optional, whether to shuffle the resulting items + +### Examples + +Full format +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +Start at 4m30s: +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --el position 270000 + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000' +``` + +Shuffle a series (`itemId` is the Series ID): +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --ez shuffle true + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&shuffle=true' +``` + +## More examples + +### Switch user + +Full format +```bash +# Switch user, go to item +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ + --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" + +# Switch user, go to home screen +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ + --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587' +``` + +### Debug build + +Debug builds have a different package, so you must specify the component by its full name: + +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` diff --git a/README.md b/README.md index 07f491a1..ed82692f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! -Seerr integration is tested with `v3.2.0`. Older versions may not work. +Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility. ## Contributions diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7230f0c..e005cb6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,12 +20,21 @@ plugins { alias(libs.plugins.openapi.generator) } -val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false -val shouldSign = isCI && System.getenv("KEY_ALIAS") != null -val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() -val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() -val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists() -val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername") +val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() } +val shouldSign = + isCI.zip( + providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() }, + ) { isCI, hasKey -> + isCI && hasKey + } +val ffmpegModuleExists = + providers.provider { project.file("libs/lib-decoder-ffmpeg-release.aar").exists() } +val av1ModuleExists = + providers.provider { project.file("libs/lib-decoder-av1-release.aar").exists() } +val mpvModuleExists = + providers.provider { project.file("libs/wholphin-mpv-release.aar").exists() } +val extensionsRepoActive = + providers.provider { project.hasProperty("WholphinExtensionsUsername") } // See https://issuetracker.google.com/issues/402800800 val isBuildingBundle = @@ -50,17 +59,22 @@ kotlin { languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 jvmTarget = JvmTarget.JVM_11 javaParameters = true + + // Workaround for https://github.com/google/ksp/issues/2964 + moduleName = "${project.group}_${project.name}" } } +private fun Provider<String>.getInt() = get().toInt() + configure<ApplicationExtension> { namespace = "com.github.damontecres.wholphin" - compileSdk = 36 + compileSdk = libs.versions.compileSdk.getInt() defaultConfig { applicationId = "com.github.damontecres.wholphin" - minSdk = 23 - targetSdk = 36 + minSdk = libs.versions.minSdk.getInt() + targetSdk = libs.versions.targetSdk.getInt() versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" @@ -69,7 +83,7 @@ configure<ApplicationExtension> { } signingConfigs { - if (shouldSign) { + if (shouldSign.get()) { create("ci") { file("ci.keystore").writeBytes( Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), @@ -95,7 +109,7 @@ configure<ApplicationExtension> { "proguard-rules.pro", ) isDebuggable = false - if (shouldSign) { + if (shouldSign.get()) { signingConfig = signingConfigs.getByName("ci") } else { val localPropertiesFile = project.rootProject.file("local.properties") @@ -166,7 +180,7 @@ configure<ApplicationExtension> { isEnable = !isBuildingBundle.get() reset() - include("armeabi-v7a", "arm64-v8a") + include("armeabi-v7a", "arm64-v8a", "x86_64") isUniversalApk = true } } @@ -323,7 +337,6 @@ dependencies { implementation(libs.androidx.room.common.jvm) implementation(libs.androidx.room.ktx) implementation(libs.androidx.compose.material3) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) @@ -351,40 +364,39 @@ dependencies { implementation(libs.kache) implementation(libs.kache.file) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.tooling) - debugImplementation(libs.androidx.compose.ui.test.manifest) coreLibraryDesugaring(libs.desugar.jdk.libs) - if (ffmpegModuleExists) { + if (ffmpegModuleExists.get()) { logger.info("Using local ffmpeg decoder") implementation(files("libs/lib-decoder-ffmpeg-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt ffmpeg decoder") implementation(libs.wholphin.extensions.ffmpeg) } else { logger.warn("Media3 ffmpeg decoder was NOT found") } - if (av1ModuleExists) { + if (av1ModuleExists.get()) { logger.info("Using local av1 decoder") implementation(files("libs/lib-decoder-av1-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt av1 decoder") implementation(libs.wholphin.extensions.av1) } else { logger.warn("Media3 av1 decoder was NOT found") } - if (mpvModuleExists) { + if (mpvModuleExists.get()) { logger.info("Using local libMPV build") implementation(files("libs/wholphin-mpv-release.aar")) - } else if (extensionsRepoActive) { + } else if (extensionsRepoActive.get()) { logger.info("Using prebuilt libMPV") implementation(libs.wholphin.extensions.mpv) } else { - logger.warn("libMPV was NOT found") + logger.warn("libMPV was NOT found, using stub library") + implementation(project(":wholphin-mpv-stub")) } + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) testImplementation(libs.kotlinx.coroutines.test) @@ -392,7 +404,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.hilt.android.testing) - androidTestImplementation(libs.androidx.ui.test.manifest) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8210c734..cddfb043 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,6 @@ -keepattributes SourceFile,LineNumberTable --keep class com.github.damontecres.wholphin.util.mpv.* { *; } +-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; } -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { @@ -11,3 +11,14 @@ # TODO investigate using smaller scope -keep class com.google.common.cache.** { *; } + +# Media3SubtitleOverride uses reflection to access/modify these fields +-keep class androidx.media3.ui.SubtitleView { + private androidx.media3.ui.SubtitleView$Output output; +} +-keep class androidx.media3.ui.CanvasSubtitleOutput { + private final java.util.List painters; +} +-keep class androidx.media3.ui.SubtitlePainter { + private final float outlineWidth; +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json new file mode 100644 index 00000000..a5709ec2 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json @@ -0,0 +1,720 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "278e485064fc9f184a2181599f0f8efb", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` 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" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "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" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '278e485064fc9f184a2181599f0f8efb')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json new file mode 100644 index 00000000..f82e6bdf --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json @@ -0,0 +1,741 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "ff9ade58d4ac39e36335ac0df1405a5f", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` 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" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "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, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', 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" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "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" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff9ade58d4ac39e36335ac0df1405a5f')" + ] + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64e7493c..582e000a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ android:installLocation="auto"> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" /> <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" /> @@ -51,13 +52,21 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density"> <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> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <action android:name="android.intent.action.SEARCH" /> + <action android:name="com.github.damontecres.wholphin.PLAYBACK" /> + + <category android:name="android.intent.category.DEFAULT" /> + <data android:scheme="wholphin" /> + </intent-filter> </activity> <provider 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 80f86954..62f0e39b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.res.Configuration import android.os.Bundle import android.view.KeyEvent import android.view.WindowManager +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -27,6 +28,8 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack 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.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend @@ -35,6 +38,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver @@ -45,6 +50,7 @@ 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.UserPreferencesService import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService @@ -52,19 +58,20 @@ import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.collectLatestIn 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.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.requestSerializersModule import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first @@ -72,14 +79,15 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -89,6 +97,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var userPreferencesDataStore: DataStore<AppPreferences> + @Inject + lateinit var userPreferencesService: UserPreferencesService + @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @@ -137,11 +148,16 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var screensaverService: ScreensaverService + @Inject + lateinit var intentService: IntentService + private var signInAuto = true + private var playerBackend: PlayerBackend? = null private val json = Json { classDiscriminator = "_type" + serializersModule = requestSerializersModule } @OptIn(ExperimentalTvMaterial3Api::class) @@ -152,37 +168,51 @@ class MainActivity : AppCompatActivity() { lifecycle.addObserver(playbackLifecycleObserver) val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) - if (backStackStr != null) { + val restoredBackStack = + if (backStackStr != null) { + try { + json.decodeFromString<List<Destination>>(backStackStr) + } catch (ex: Exception) { + // Best-effort: a previously persisted back stack we can no longer decode + // must not crash startup; fall back to a fresh start destination. + Timber.w(ex, "Could not restore back stack; starting fresh") + null + } + } else { + null + } + if (restoredBackStack != null) { Timber.d("Restoring back stack") - var backStack = json.decodeFromString<List<Destination>>(backStackStr) - + var backStack = restoredBackStack if (!playExternalViewModel.launched.value) { val lastDest = backStack.lastOrNull() if (lastDest.isPlayback) { - Timber.v("Restoring back stack with playback") + Timber.v("Restoring back stack without playback") backStack = backStack.toMutableList().apply { removeAt(lastIndex) } } } navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) } else { - val startDestination = intent?.let(::extractDestination) ?: Destination.Home() - navigationManager.backStack = NavBackStack(startDestination) + navigationManager.backStack = NavBackStack(Destination.Home()) } - viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user -> - if (user?.hasPin == true) { - window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - } else { - window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } + viewModel.serverRepository.currentUserFlow + .onEach { user -> + withContext(WholphinDispatchers.Main) { + if (user?.hasPin == true) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + }.catch { ex -> + Timber.e(ex, "Error with settings flag secure") + }.launchIn(lifecycleScope) + screensaverService.keepScreenOn .onEach { keepScreenOn -> Timber.v("keepScreenOn: %s", keepScreenOn) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -193,55 +223,63 @@ class MainActivity : AppCompatActivity() { Timber.e(ex, "Error with keepScreenOn") }.launchIn(lifecycleScope) - viewModel.appStart() + userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs -> + signInAuto = prefs.signInAutomatically + playerBackend = prefs.playbackPreferences.playerBackend + } + + viewModel.appStart(intent) 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 - } - CoilConfig( - prefs = appPreferences, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, - ) - LaunchedEffect(appPreferences.debugLogging) { - DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging - } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, - ) { - ProvideLocalClock { - MainContent( - backStack = setupNavigationManager.backStack, - navigationManager = navigationManager, - appPreferences = appPreferences, - backdropService = backdropService, - screensaverService = screensaverService, - modifier = Modifier.fillMaxSize(), + MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) { + Surface(Modifier.fillMaxSize()) { + val userPreferences by userPreferencesService.flow.collectAsState(null) + if (userPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500.milliseconds) + Timber.i("Showing loading page") + showLoading = true + } + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + LoadingPage() + } + } + } else { + userPreferences?.let { userPreferences -> + val appPreferences = userPreferences.appPreferences + CoilConfig( + prefs = appPreferences, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, ) + LaunchedEffect(appPreferences.debugLogging) { + DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging + } + CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { + WholphinTheme( + true, + appThemeColors = appPreferences.interfacePreferences.appThemeColors, + ) { + ProvideLocalClock { + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + userPreferences = userPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + modifier = Modifier.fillMaxSize(), + ) + } + } + } } } } @@ -250,7 +288,7 @@ class MainActivity : AppCompatActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (screensaverService.state.value.show) { + if (screensaverService.state.value.run { show || showDim }) { screensaverService.stop(false) screensaverService.pulse() return true @@ -263,6 +301,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() Timber.d("onResume") + viewModel.appResume() lifecycleScope.launchDefault { screensaverService.pulse() } @@ -271,7 +310,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - viewModel.appStart() + viewModel.appStart(null) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() @@ -318,10 +357,14 @@ class MainActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") - val str = json.encodeToString(navigationManager.backStack.toList()) - outState.putString(KEY_BACK_STACK, str) - val playerBackend = - runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend + try { + val str = json.encodeToString(navigationManager.backStack.toList()) + outState.putString(KEY_BACK_STACK, str) + } catch (ex: Exception) { + // Best-effort: some destinations carry types we can't serialize; skip persisting + // the back stack rather than crashing in onSaveInstanceState. + Timber.w(ex, "Could not persist back stack; skipping") + } outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) } @@ -337,67 +380,34 @@ class MainActivity : AppCompatActivity() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - Timber.d("onConfigurationChanged") + Timber.d("onConfigurationChanged: newConfig=%s", newConfig) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") setIntent(intent) - extractDestination(intent)?.let { - navigationManager.replace(it) - } + viewModel.appStart(intent) } - private fun extractDestination(intent: Intent): Destination? = - intent.let { - val itemId = - it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() - val type = - it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) - if (itemId != null && type != null) { - val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() - val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() - val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) - val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) - if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { - Destination.SeriesOverview( - itemId = seriesId, - type = BaseItemKind.SERIES, - seasonEpisode = - SeasonEpisodeIds( - seasonId = seasonId, - seasonNumber = seasonNumber, - episodeId = itemId, - episodeNumber = episodeNumber, - ), - ) - } else { - Destination.MediaItem(itemId, type) - } - } else { - null - } - } - 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 } + lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { + try { + val attrs = window.attributes + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } + } + } catch (ex: Exception) { + Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId) + Toast + .makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT) + .show() } } } companion object { - const val INTENT_ITEM_ID = "itemId" - const val INTENT_ITEM_TYPE = "itemType" - const val INTENT_SERIES_ID = "seriesId" - const val INTENT_EPISODE_NUMBER = "epNum" - const val INTENT_SEASON_NUMBER = "seaNum" - const val INTENT_SEASON_ID = "seaId" - private const val KEY_BACK_STACK = "backStack" private const val KEY_EXTERNAL_PLAYER = "extPlayer" @@ -413,68 +423,137 @@ class MainActivityViewModel @param:ApplicationContext private val context: Context, private val preferences: DataStore<AppPreferences>, val serverRepository: ServerRepository, - private val navigationManager: SetupNavigationManager, + private val setupNavigationManager: SetupNavigationManager, + private val navigationManager: NavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, private val appUpgradeHandler: AppUpgradeHandler, + private val intentService: IntentService, ) : ViewModel() { - fun appStart() { + private val mutex = Mutex() + + fun appStart(intent: Intent?) { viewModelScope.launchDefault { - try { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { - showToast( - context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), - ) - appUpgradeHandler.run() + mutex.withLock { + try { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, result.message) + return@withLock + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + + if (result.addHomeToBackStack) { + navigationManager.reloadHome() + } else { + navigationManager.backStack.clear() + } + navigationManager.backStack.addAll(result.destinations) + + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return@withLock + } + + IntentResult.NoOp, + null, + -> { + // No-op, proceed below + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error parsing intent") } - appUpgradeHandler.copySubfont(false) - val prefs = - preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val profileProtected = - serverRepository.current.value?.user?.let { - it.hasPin || it.requireLogin - } == true - if (prefs.signInAutomatically && !profileProtected) { - val current = - serverRepository.restoreSession( - prefs.currentServerId?.toUUIDOrNull(), - prefs.currentUserId?.toUUIDOrNull(), + + try { + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), ) - if (current != null) { - if (current.user.hasPin || current.user.requireLogin) { - navigationManager.navigateTo(SetupDestination.UserList(current.server)) + appUpgradeHandler.run() + } + appUpgradeHandler.copySubfont(false) + val prefs = + preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val profileProtected = + serverRepository.current.value + ?.user + ?.isProtected == true + if (prefs.signInAutomatically && !profileProtected) { + val current = + serverRepository.restoreSession( + prefs.currentServerId?.toUUIDOrNull(), + prefs.currentUserId?.toUUIDOrNull(), + ) + if (current != null) { + if (current.user.isProtected) { + setupNavigationManager.navigateTo( + SetupDestination.UserList( + current.server, + ), + ) + } else { + // Restored + setupNavigationManager.navigateTo( + SetupDestination.AppContent( + current, + ), + ) + } } else { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) + // Did not restore + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } else { - // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - navigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - serverRepository.serverDao.getServer(currentServerId)?.server - if (currentServer != null) { - navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + setupNavigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + serverRepository.serverDao.getServer(currentServerId)?.server + if (currentServer != null) { + setupNavigationManager.navigateTo( + SetupDestination.UserList( + currentServer, + ), + ) + } else { + setupNavigationManager.navigateTo(SetupDestination.ServerList) + } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } - } else { - navigationManager.navigateTo(SetupDestination.ServerList) } + } catch (ex: Exception) { + Timber.e(ex, "Error during appStart") + setupNavigationManager.navigateTo(SetupDestination.ServerList) } - } catch (ex: Exception) { - Timber.e(ex, "Error during appStart") - navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchDefault { @@ -482,6 +561,19 @@ class MainActivityViewModel deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() } } + + fun appResume() { + viewModelScope.launchDefault { + mutex.withLock { + try { + Timber.v("Updating userDto") + serverRepository.updateUserDto() + } catch (ex: Exception) { + Timber.w(ex, "Error during appResume") + } + } + } + } } private val Destination?.isPlayback: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 714d80dc..26dda908 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState 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.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -27,7 +28,6 @@ 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 @@ -44,12 +44,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization fun MainContent( backStack: MutableList<SetupDestination>, navigationManager: NavigationManager, - appPreferences: AppPreferences, + userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, modifier: Modifier = Modifier, ) { - val preferences by rememberUpdatedState(UserPreferences(appPreferences)) + val preferences by rememberUpdatedState(userPreferences) Surface( modifier = modifier @@ -59,7 +59,7 @@ fun MainContent( // val backStack = rememberNavBackStack(SetupDestination.Loading) // setupNavigationManager.backStack = backStack val interfaceCustomization = - remember(appPreferences) { InterfaceCustomization(appPreferences) } + remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) } CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) { NavDisplay( backStack = backStack, @@ -105,7 +105,7 @@ fun MainContent( mutableStateOf(true) } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!appPreferences.signInAutomatically) { + if (!userPreferences.appPreferences.signInAutomatically) { showContent = false } } @@ -137,12 +137,28 @@ fun MainContent( val screenSaverState by screensaverService.state.collectAsState() if (screenSaverState.enabled || screenSaverState.enabledTemp) { AnimatedVisibility( - screenSaverState.show, - Modifier.fillMaxSize(), + visible = screenSaverState.show, + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = Modifier.fillMaxSize(), ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) + AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize()) } } + AnimatedVisibility( + visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show), + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = Modifier.fillMaxSize(), + ) { + val alpha = + userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)), + ) + } } } } 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 ceb7264e..bd25a2d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -15,6 +15,8 @@ import org.acra.ReportField import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber import javax.inject.Inject @@ -120,5 +122,7 @@ class WholphinApplication : companion object { lateinit var instance: WholphinApplication private set + + val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index a281b101..57d4a447 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin import android.service.dreams.DreamService +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -9,6 +11,7 @@ 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.ComposeView import androidx.datastore.core.DataStore import androidx.lifecycle.Lifecycle @@ -104,13 +107,23 @@ class WholphinDreamService : ProvideLocalClock { val screensaverPrefs = prefs.interfacePreferences.screensaverPreference val currentItem by itemFlow.collectAsState(null) - AppScreensaverContent( - currentItem = currentItem, - showClock = screensaverPrefs.showClock, - duration = screensaverPrefs.duration.milliseconds, - animate = screensaverPrefs.animate, - modifier = Modifier.fillMaxSize(), - ) + Box(Modifier.fillMaxSize()) { + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + if (screensaverPrefs.dimEnabled) { + val alpha = screensaverPrefs.dimPercent / 100f + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)), + ) + } + } } } } 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 69f2e745..6a691723 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 @@ -16,6 +16,7 @@ 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.RememberedTab import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions @@ -46,8 +47,9 @@ import java.util.UUID ItemTrackModification::class, SeerrServer::class, SeerrUser::class, + RememberedTab::class, ], - version = 33, + version = 35, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -64,6 +66,8 @@ import java.util.UUID AutoMigration(30, 31), AutoMigration(31, 32), AutoMigration(32, 33), + AutoMigration(33, 34), + AutoMigration(34, 35), ], ) @TypeConverters(Converters::class) @@ -81,6 +85,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun seerrServerDao(): SeerrServerDao abstract fun playbackEffectDao(): PlaybackEffectDao + + abstract fun rememberedTabDao(): RememberedTabDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index ff568a37..490103b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -27,6 +27,7 @@ sealed interface ExtrasItem { val subtitle: String? val isPlayed: Boolean val playedPercentage: Double + val aspectRatio: Float? /** * Represents multiple extras of the same type @@ -39,6 +40,7 @@ sealed interface ExtrasItem { override val title: String, override val subtitle: String, override val isPlayed: Boolean, + override val aspectRatio: Float?, ) : ExtrasItem { override val destination: Destination = Destination.ItemGrid( @@ -82,6 +84,8 @@ sealed interface ExtrasItem { get() = item.played override val playedPercentage get() = item.data.userData?.playedPercentage ?: 0.0 + override val aspectRatio: Float? + get() = item.aspectRatio } } @@ -92,18 +96,18 @@ sealed interface ExtrasItem { val ExtraType.stringRes: Int get() = when (this) { - ExtraType.UNKNOWN -> R.string.other_extras - ExtraType.CLIP -> R.string.clips - ExtraType.TRAILER -> R.string.trailers - ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes - ExtraType.DELETED_SCENE -> R.string.deleted_scenes - ExtraType.INTERVIEW -> R.string.interviews - ExtraType.SCENE -> R.string.scenes - ExtraType.SAMPLE -> R.string.samples - ExtraType.THEME_SONG -> R.string.theme_songs - ExtraType.THEME_VIDEO -> R.string.theme_videos - ExtraType.FEATURETTE -> R.string.featurettes - ExtraType.SHORT -> R.string.shorts + ExtraType.UNKNOWN -> R.string.other_extras_title + ExtraType.CLIP -> R.string.clips_title + ExtraType.TRAILER -> R.string.trailers_title + ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title + ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title + ExtraType.INTERVIEW -> R.string.interviews_title + ExtraType.SCENE -> R.string.scenes_title + ExtraType.SAMPLE -> R.string.samples_title + ExtraType.THEME_SONG -> R.string.theme_songs_title + ExtraType.THEME_VIDEO -> R.string.theme_videos_title + ExtraType.FEATURETTE -> R.string.featurettes_title + ExtraType.SHORT -> R.string.shorts_title } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 80a2c45f..c3afbc33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -7,7 +7,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.StreamChoiceService -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -19,6 +19,9 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +/** + * Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams] + */ @Singleton class ItemPlaybackRepository @Inject @@ -28,6 +31,13 @@ class ItemPlaybackRepository private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val streamChoiceService: StreamChoiceService, ) { + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the user ([ItemPlayback]) or the default choices + * + * @see getChosenItemFromPlayback + */ suspend fun getSelectedTracks( itemId: UUID, item: BaseItem, @@ -40,6 +50,11 @@ class ItemPlaybackRepository return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) } + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null + */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, @@ -86,11 +101,14 @@ class ItemPlaybackRepository } } + /** + * Save the choice for a specific media source for an item + */ suspend fun savePlayVersion( itemId: UUID, sourceId: UUID, ): ItemPlayback? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverRepository.currentUser?.let { user -> val itemPlayback = ItemPlayback( @@ -103,6 +121,11 @@ class ItemPlaybackRepository } } + /** + * Save the chosen track (index & type) for an item + * + * Additionally, if the item is part of a TV Series, store the language as the preferred one for this series + */ suspend fun saveTrackSelection( item: BaseItem, itemPlayback: ItemPlayback?, @@ -180,6 +203,9 @@ class ItemPlaybackRepository return toSave.copy(rowId = id) } + /** + * Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any + */ suspend fun getTrackModifications( itemId: UUID, trackIndex: Int, @@ -188,6 +214,9 @@ class ItemPlaybackRepository itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) } + /** + * Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem] + */ suspend fun saveTrackModifications( itemId: UUID, trackIndex: Int, @@ -206,6 +235,9 @@ class ItemPlaybackRepository } } + /** + * Clear the user's source/stream choices for an item + */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) chosenStreams?.plc?.let { @@ -219,6 +251,9 @@ class ItemPlaybackRepository } } +/** + * Info about which media source and streams the user/app has chosen for playback + */ data class ChosenStreams( val itemPlayback: ItemPlayback?, val plc: PlaybackLanguageChoice?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index 3c2250fb..f72eb3b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -9,6 +9,7 @@ import androidx.room.Update import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.data.model.JellyfinUser +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -56,6 +57,12 @@ interface JellyfinServerDao { userId: UUID, ): JellyfinUser? + @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") + fun getUserFlow( + serverId: UUID, + userId: UUID, + ): Flow<JellyfinUser?> + @Query("DELETE FROM servers WHERE id = :serverId") fun deleteServer(serverId: UUID) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt new file mode 100644 index 00000000..13398228 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.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.RememberedTab + +@Dao +interface RememberedTabDao { + @Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId") + suspend fun getRememberedTab( + userId: Int, + itemId: String, + ): RememberedTab? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(item: RememberedTab): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveAll(items: List<RememberedTab>) +} 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 05cc9bd3..e6d9c468 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,13 +9,17 @@ 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.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin @@ -56,7 +60,18 @@ class ServerRepository val currentServer: JellyfinServer? get() = _current.value?.server val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server } val currentUser: JellyfinUser? get() = _current.value?.user - val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user } + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserFlow: Flow<JellyfinUser?> + get() = + _current + .flatMapLatest { user -> + if (user?.user != null) { + serverDao.getUserFlow(user.user.serverId, user.user.id) + } else { + flow { emit(null) } + } + } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -109,7 +124,7 @@ class ServerRepository }.build() } val currentUser = CurrentUser(updatedServer, updatedUser) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _current.value = currentUser _currentUserDto.value = userDto } @@ -222,7 +237,7 @@ class ServerRepository suspend fun removeUser(user: JellyfinUser) { if (current.value?.user?.id == user.id) { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _current.value = null } userPreferencesDataStore.updateData { @@ -241,7 +256,7 @@ class ServerRepository suspend fun removeServer(server: JellyfinServer) { if (current.value?.server?.id == server.id) { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _current.value = null } userPreferencesDataStore.updateData { @@ -298,6 +313,16 @@ class ServerRepository response.content } + /** + * Update [currentUserDto] by querying the server + */ + suspend fun updateUserDto() { + val userDto by apiClient.userApi.getCurrentUser() + _currentUserDto.update { + if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it + } + } + companion object { fun getServerSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt new file mode 100644 index 00000000..446a64c0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt @@ -0,0 +1,287 @@ +package com.github.damontecres.wholphin.data.filter + +import androidx.annotation.StringRes +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.SearchApi +import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet +import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response +import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus +import com.github.damontecres.wholphin.ui.data.flip +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.SortOrder +import java.time.format.DateTimeFormatter +import kotlin.time.Duration + +private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d") + +enum class DiscoverSort( + val key: String, + @param:StringRes val stringRes: Int, + val tvKey: String = key, +) { + POPULARITY("popularity", R.string.popularity), + RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"), + TMDB_VOTE("vote_average", R.string.community_rating), + ALPHABETICAL("original_title", R.string.sort_by_name), +} + +private val SortOrder.key get() = + when (this) { + SortOrder.ASCENDING -> "asc" + SortOrder.DESCENDING -> "desc" + } + +@Serializable +data class DiscoverSortAndDirection( + val sort: DiscoverSort = DiscoverSort.POPULARITY, + val direction: SortOrder = SortOrder.DESCENDING, +) { + fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}" + + fun flip() = copy(direction = direction.flip()) +} + +@Serializable +data class DiscoverFilter( + // /api/v1/languages + val language: String? = null, + val genreIds: List<Int>? = null, + val networkIds: List<Int>? = null, + // /api/v1/search/keyword + val keywordIds: List<Int>? = null, + val excludeKeywordIds: List<Int>? = null, + val sortBy: DiscoverSortAndDirection? = null, +// val firstAirDateGte: LocalDate? = null, +// val firstAirDateLte: LocalDate? = null, + val withRuntimeGte: Duration? = null, + val withRuntimeLte: Duration? = null, + val voteAverageGte: Int? = null, + val voteAverageLte: Int? = null, + val voteCountGte: Int? = null, + val voteCountLte: Int? = null, + // /api/v1/watchproviders/regions + val watchRegion: String? = null, // US + // networkIds? + // val watchProviders: String? = null, + val status: List<TvShowStatus>? = null, + val certification: List<String>? = null, +// val certificationGte: String? = null, +// val certificationLte: String? = null, +// val certificationCountry: String? = null, // US +// val certificationMatchExact: Boolean? = null, +) { + /** + * Returns how many of filters are actually being used in this + */ + fun countFilters(filterOptions: List<DiscoverFilterBy<*>>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this) != null) count++ + } + return count + } + + /** + * Clear all the values for the given filters + */ + fun delete(filterOptions: List<DiscoverFilterBy<*>>): DiscoverFilter { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter) + } + return newFilter + } + + suspend fun discoverTv( + searchApi: SearchApi, + page: Int, + ): DiscoverTvGet200Response = + searchApi.discoverTvGet( + page = page, + language = language, + genre = genreIds?.joinToString(",") { it.toString() }, + network = null, + keywords = keywordIds?.joinToString(",") { it.toString() }, + excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, + sortBy = sortBy?.key(true), +// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, +// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, + withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), + withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(), + voteAverageGte = voteAverageGte, + voteAverageLte = voteAverageLte, + voteCountGte = voteCountGte, + voteCountLte = voteCountLte, + // TODO +// watchRegion = watchRegion, + watchRegion = networkIds?.let { "US" }, + watchProviders = networkIds?.joinToString(",") { it.toString() }, + status = status?.joinToString("|") { it.ordinal.toString() }, + certification = certification?.joinToString("|"), +// certificationGte = certificationGte, +// certificationLte = certificationLte, + certificationCountry = certification?.let { "US" }, + certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT }, + ) + + suspend fun discoverMovies( + searchApi: SearchApi, + page: Int, + ): DiscoverMoviesGet200Response = + searchApi.discoverMoviesGet( + page = page, + language = language, + genre = genreIds?.joinToString(",") { it.toString() }, +// network = null, + keywords = keywordIds?.joinToString(",") { it.toString() }, + excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() }, + sortBy = sortBy?.key(false), +// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) }, +// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) }, + withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(), + withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(), + voteAverageGte = voteAverageGte, + voteAverageLte = voteAverageLte, + voteCountGte = voteCountGte, + voteCountLte = voteCountLte, + // TODO +// watchRegion = watchRegion, + watchRegion = networkIds?.let { "US" }, + watchProviders = networkIds?.joinToString(",") { it.toString() }, +// status = status, + certification = certification?.joinToString("|"), +// certificationGte = certificationGte, +// certificationLte = certificationLte, + certificationCountry = certification?.let { "US" }, + certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT }, + ) +} + +val discoverMovieFilters = + listOf( + DiscoverMovieGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverRatingFilter, + DiscoverMovieContentRatingFilter, + ) + +val discoverTvFilters = + listOf( + DiscoverTvGenreFilter, + DiscoverTvStudiosFilter, + DiscoverRatingFilter, + DiscoverTvContentRatingFilter, + DiscoverTvStatusFilter, + ) + +/** + * A way to filter discover + * + * Gets and sets values within a [DiscoverFilter] + */ +sealed interface DiscoverFilterBy<T> : FilterBy<DiscoverFilter, T> + +data object DiscoverMovieGenreFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverTvGenreFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(genreIds = value) +} + +data object DiscoverMovieStudiosFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds + + override fun set( + value: List<Int>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(networkIds = value) +} + +data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvShowStatus>> { + override val stringRes: Int = R.string.production_status + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<TvShowStatus>? = filter.status + + override fun set( + value: List<TvShowStatus>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(status = value) +} + +data object DiscoverMovieContentRatingFilter : DiscoverFilterBy<List<String>> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<String>? = filter.certification + + override fun set( + value: List<String>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverTvContentRatingFilter : DiscoverFilterBy<List<String>> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): List<String>? = filter.certification + + override fun set( + value: List<String>?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(certification = value) +} + +data object DiscoverRatingFilter : DiscoverFilterBy<Int> { + override val stringRes: Int = R.string.community_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte + + override fun set( + value: Int?, + filter: DiscoverFilter, + ): DiscoverFilter = filter.copy(voteAverageGte = value) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 37fd9514..b92d251f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions = ) /** - * A way to filter libraries - * - * Gets and sets values within a [GetItemsFilter] + * Abstract way to filter data */ -sealed interface ItemFilterBy<T> { +sealed interface FilterBy<FilterType, DataType> { @get:StringRes val stringRes: Int val supportMultiple: Boolean - fun get(filter: GetItemsFilter): T? + fun get(filter: FilterType): DataType? fun set( - value: T?, - filter: GetItemsFilter, - ): GetItemsFilter + value: DataType?, + filter: FilterType, + ): FilterType } +/** + * A way to filter jellyfin libraries + * + * Gets and sets values within a [GetItemsFilter] + */ +sealed interface ItemFilterBy<T> : FilterBy<GetItemsFilter, T> + data object GenreFilter : ItemFilterBy<List<UUID>> { override val stringRes: Int = R.string.genres diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt new file mode 100644 index 00000000..6b30e9ff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.data.filter + +import com.github.damontecres.wholphin.data.model.GetItemsFilter + +interface SearchFilter<T : SearchFilter<T>> { + /** + * Returns how many of filters are actually being used in this [GetItemsFilter] + */ + fun countFilters(filterOptions: List<FilterBy<T, *>>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this as T) != null) count++ + } + return count + } + + /** + * Clear all the values for the given filters + */ + fun delete(filterOptions: List<FilterBy<T, *>>): T { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter as T) + } + return newFilter as T + } +} 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 e6e70625..eefb001b 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 @@ -5,13 +5,18 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable 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 com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatEpisodeNumber import com.github.damontecres.wholphin.ui.getDateFormatter +import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.roundMinutes @@ -19,11 +24,13 @@ import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears import com.github.damontecres.wholphin.ui.timeRemaining +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient 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.extensions.ticks import java.util.Locale import java.util.UUID @@ -48,7 +55,7 @@ data class BaseItem( get() = type.playable override val sortName: String - get() = data.sortName ?: data.name ?: "" + get() = data.alphabetSortName val type get() = data.type @@ -56,14 +63,14 @@ data class BaseItem( val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name - val subtitle - get() = - when (type) { - BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name - BaseItemKind.SERIES -> data.seriesProductionYears - BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ") - else -> data.productionYear?.toString() - } + val subtitle: String? by lazy { + when (type) { + BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") + BaseItemKind.SERIES -> data.seriesProductionYears + BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") + else -> data.productionYear?.toString() + } + } val subtitleLong: String? by lazy { if (type == BaseItemKind.EPISODE) { @@ -71,7 +78,7 @@ data class BaseItem( add(data.seasonEpisodePadded) add(data.name) add(data.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") + }.joinNotBlank(" - ") } else { data.productionYear?.toString() } @@ -91,7 +98,12 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks + val timeRemainingOrRuntime: Duration? + get() = + when (type) { + BaseItemKind.PROGRAM -> null + else -> data.timeRemaining ?: data.runTimeTicks?.ticks + } /** * Contains pre computed UI elements that would be expensive to create on the main thread @@ -100,7 +112,7 @@ data class BaseItem( val ui = BaseItemUi( episodeCornerText = - data.indexNumber?.let { "E$it" } + data.indexNumber?.let { formatEpisodeNumber(it) } ?: data.premiereDate?.let(::formatDateTime), episodeUnplayedCornerText = if (type == BaseItemKind.SERIES || @@ -108,7 +120,7 @@ data class BaseItem( type == BaseItemKind.EPISODE || type == BaseItemKind.BOX_SET ) { - data.indexNumber?.let { "E$it" } + data.indexNumber?.let { formatEpisodeNumber(it) } ?: data.userData ?.unplayedItemCount ?.takeIf { it > 0 } @@ -136,16 +148,36 @@ data class BaseItem( } else if (type == BaseItemKind.BOX_SET) { data.productionYear?.let { add(it.toString()) } data.childCount?.let { add("$it items") } + } else if (type == BaseItemKind.PROGRAM) { + data.channelName?.let(::add) + if (data.isSeries == true) { + // TV episode + data.seasonEpisode?.let(::add) + data.premiereDate?.let { + add(getDateFormatter().format(it)) + } + } else { + data.productionYear?.let { add(it.toString()) } + } } else { data.productionYear?.let { add(it.toString()) } } data.runTimeTicks ?.ticks ?.roundMinutes - ?.let { add(it.toString()) } + ?.takeIf { it > Duration.ZERO } + ?.let { add(WholphinApplication.instance.resources.formatDuration(it)) } data.timeRemaining ?.roundMinutes - ?.let { add("$it left") } + ?.let { + val resources = WholphinApplication.instance.resources + add( + resources.getString( + R.string.time_left, + resources.formatDuration(it), + ), + ) + } } details.forEachIndexed { index, string -> append(string) @@ -197,25 +229,38 @@ data class BaseItem( */ fun destination(index: Int? = null): Destination { if (destinationOverride != null) return destinationOverride + if (data.extraType != null || type == BaseItemKind.TRAILER) { + // Extras including trailers should always play directly + return Destination.Playback(id, 0) + } val result = // Redirect episodes & seasons to their series if possible when (type) { BaseItemKind.EPISODE -> { - data.seasonId?.let { seasonId -> + val seasonId = data.seasonId + val seriesId = data.seriesId + if (seasonId != null && seriesId != null) { Destination.SeriesOverview( - data.seriesId!!, + seriesId, BaseItemKind.SERIES, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } ?: Destination.MediaItem(this) + } else { + Destination.MediaItem(this) + } } BaseItemKind.SEASON -> { - Destination.SeriesOverview( - data.seriesId!!, - BaseItemKind.SERIES, - SeasonEpisodeIds(id, indexNumber, null, null), - ) + val seriesId = data.seriesId + if (seriesId != null) { + Destination.SeriesOverview( + seriesId, + BaseItemKind.SERIES, + SeasonEpisodeIds(id, indexNumber, null, null), + ) + } else { + Destination.MediaItem(this) + } } BaseItemKind.TV_CHANNEL -> { @@ -237,6 +282,16 @@ data class BaseItem( } } + BaseItemKind.AUDIO -> { + data.albumId?.let { albumId -> + Destination.MediaItem( + itemId = albumId, + type = BaseItemKind.MUSIC_ALBUM, + initialSongId = id, + ) + } ?: Destination.MediaItem(this) + } + else -> { Destination.MediaItem(this) } @@ -260,6 +315,9 @@ data class BaseItem( val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } +/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */ +val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: "" + @Immutable data class BaseItemUi( val episodeCornerText: String?, @@ -283,6 +341,7 @@ fun createGenreDestination( parentId: UUID, parentName: String?, includeItemTypes: List<BaseItemKind>?, + collectionType: CollectionType, ) = Destination.FilteredCollection( itemId = parentId, parentType = BaseItemKind.GENRE, @@ -298,9 +357,11 @@ fun createGenreDestination( genres = listOf(genreId), includeItemTypes = includeItemTypes, ), - useSavedLibraryDisplayInfo = false, + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres", ), recursive = true, + collectionType = collectionType, ) fun createStudioDestination( @@ -324,9 +385,11 @@ fun createStudioDestination( studios = listOf(studioId), includeItemTypes = includeItemTypes, ), - useSavedLibraryDisplayInfo = false, + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios", ), recursive = true, + collectionType = CollectionType.UNKNOWN, ) val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty() 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 3e07964c..71a300fb 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 @@ -1,38 +1,30 @@ package com.github.damontecres.wholphin.data.model -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID import kotlin.time.Duration /** * Represents a chapter within a video */ data class Chapter( + val itemId: UUID, val name: String?, val position: Duration, - val imageUrl: String?, + val tag: String?, + val index: Int, ) { companion object { - fun fromDto( - dto: BaseItemDto, - api: ApiClient, - ): List<Chapter> = + fun fromDto(dto: BaseItemDto): List<Chapter> = dto.chapters ?.mapIndexed { index, chapter -> Chapter( - chapter.name, - chapter.startPositionTicks.ticks, - chapter.imageTag?.let { - api.imageApi.getItemImageUrl( - itemId = dto.id, - imageType = ImageType.CHAPTER, - tag = it, - imageIndex = index, - ) - }, + itemId = dto.id, + name = chapter.name, + position = chapter.startPositionTicks.ticks, + tag = chapter.imageTag, + index = index, ) }?.sortedBy { it.position } .orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index db03ff48..24d7d340 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -56,12 +56,14 @@ enum class SeerrItemType( enum class SeerrAvailability( val status: Int, ) { + // https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14 UNKNOWN(1), PENDING(2), PROCESSING(3), PARTIALLY_AVAILABLE(4), AVAILABLE(5), - DELETED(6), + BLOCKLISTED(6), + DELETED(7), ; companion object { @@ -69,6 +71,23 @@ enum class SeerrAvailability( } } +@Serializable +enum class RequestStatus( + val status: Int, +) { + UNKNOWN(0), + PENDING(1), + APPROVED(2), + DECLINED(3), + FAILURE(4), + COMPLETED(5), + ; + + companion object { + fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN + } +} + /** * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. */ @@ -84,6 +103,7 @@ data class DiscoverItem( @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, val posterUrl: String?, val backDropUrl: String?, + val logoUrl: String?, val jellyfinItemId: UUID?, ) : CardGridItem { override val gridId: String get() = id.toString() @@ -92,13 +112,7 @@ data class DiscoverItem( val destination: Destination get() { - val jfType = - when (type) { - SeerrItemType.MOVIE -> BaseItemKind.MOVIE - SeerrItemType.TV -> BaseItemKind.SERIES - SeerrItemType.PERSON -> BaseItemKind.PERSON - SeerrItemType.UNKNOWN -> null - } + val jfType = type.baseItemKind return if (jellyfinItemId != null && jfType != null) { Destination.MediaItem( itemId = jellyfinItemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index d0d32988..3746092b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.VideoType +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -22,9 +23,13 @@ data class CollectionFolderFilter( val nameOverride: String? = null, val filter: GetItemsFilter = GetItemsFilter(), /** - * Whether to use the libray's saved sort & filter + * Whether to use the library's saved sort & filter */ val useSavedLibraryDisplayInfo: Boolean = true, + /** + * Use a different ID for the library's saved sort & filter, such as for library's genres + */ + val libraryDisplayInfoIdOverride: String? = null, ) /** @@ -134,6 +139,22 @@ data class GetItemsFilter( isFavorite = favorite, ) + fun applyTo(req: GetArtistsRequest) = + req.copy( + minCommunityRating = minCommunityRating, + isFavorite = favorite, + genreIds = genres, + personIds = persons, + studioIds = studios, + tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + ) + /** * Merge another [GetItemsFilter] onto this one, replacing only unset values */ @@ -158,4 +179,5 @@ data class GetItemsFilter( enum class GetItemsFilterOverride { NONE, PERSON, + ARTIST, } 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 index 74a6c6d1..4914248f 100644 --- 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 @@ -109,15 +109,7 @@ sealed interface HomeRowConfig { @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() - }, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) } @@ -243,7 +235,7 @@ data class HomeRowViewOptions( val liveTvDefault = HomeRowViewOptions( - heightDp = 96, + heightDp = Cards.HEIGHT_LIVE_TV, aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.FIT, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index a357de4a..4c54311c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -11,6 +11,8 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -65,11 +67,17 @@ data class JellyfinUser( val requireLogin: Boolean = false, val lastUsed: ZonedDateTime? = null, val uiLanguage: String? = null, + @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() + val isProtected: Boolean get() = hasPin || requireLogin + override fun toString(): String = - "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + + "appPreferences=$appPreferences" } /** @@ -83,3 +91,13 @@ data class JellyfinServerUsers( ) val users: List<JellyfinUser>, ) + +@Serializable +data class JellyfinUserPreferences( + @ColumnInfo(defaultValue = "") + val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "") + val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "USE_USER_PROFILE") + val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 3e3cbe18..9f10f58c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -1,8 +1,5 @@ package com.github.damontecres.wholphin.data.model -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.setValue import org.jellyfin.sdk.model.api.MediaType import java.util.UUID @@ -11,38 +8,13 @@ import java.util.UUID * * This is not the same thing as a Jellyfin server playlist */ -class Playlist( - items: List<PlaylistItem>, - startIndex: Int = 0, +data class Playlist( + val items: List<PlaylistItem>, ) { - val items = items.subList(startIndex, items.size) - - var index by mutableIntStateOf(0) - - fun hasPrevious(): Boolean = index > 0 - - fun hasNext(): Boolean = (index + 1) < items.size - - fun getPreviousAndReverse(): PlaylistItem = items[--index] - - fun getAndAdvance(): PlaylistItem = items[++index] - - fun peek(): PlaylistItem? = items.getOrNull(index + 1) - - fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size) - - fun advanceTo(id: UUID): PlaylistItem? { - while (hasNext()) { - val potential = getAndAdvance() - if (potential.id == id) { - return potential - } - } - return null - } - companion object { const val MAX_SIZE = 100 + + fun fromMedia(list: List<BaseItem>) = Playlist(list.map { PlaylistItem.Media(it) }) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt new file mode 100644 index 00000000..2fc6f702 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + indices = [Index("userId", "itemId", unique = true)], + primaryKeys = ["userId", "itemId"], +) +data class RememberedTab( + val userId: Int, + val itemId: String, + val index: Int, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index a6886cf2..aafdebf7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig */ enum class SeerrPermission( private val flag: Int, + vararg val parents: SeerrPermission, ) { // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts NONE(0), @@ -17,28 +18,28 @@ enum class SeerrPermission( REQUEST(32), VOTE(64), AUTO_APPROVE(128), - AUTO_APPROVE_MOVIE(256), - AUTO_APPROVE_TV(512), + AUTO_APPROVE_MOVIE(256, AUTO_APPROVE), + AUTO_APPROVE_TV(512, AUTO_APPROVE), REQUEST_4K(1024), - REQUEST_4K_MOVIE(2048), - REQUEST_4K_TV(4096), - REQUEST_ADVANCED(8192), - REQUEST_VIEW(16384), + REQUEST_4K_MOVIE(2048, REQUEST_4K), + REQUEST_4K_TV(4096, REQUEST_4K), + REQUEST_ADVANCED(8192, MANAGE_REQUESTS), + REQUEST_VIEW(16384, MANAGE_REQUESTS), AUTO_APPROVE_4K(32768), - AUTO_APPROVE_4K_MOVIE(65536), - AUTO_APPROVE_4K_TV(131072), - REQUEST_MOVIE(262144), - REQUEST_TV(524288), + AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K), + AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K), + REQUEST_MOVIE(262144, REQUEST), + REQUEST_TV(524288, REQUEST), MANAGE_ISSUES(1048576), - VIEW_ISSUES(2097152), - CREATE_ISSUES(4194304), + VIEW_ISSUES(2097152, MANAGE_ISSUES), + CREATE_ISSUES(4194304, MANAGE_ISSUES), AUTO_REQUEST(8388608), - AUTO_REQUEST_MOVIE(16777216), - AUTO_REQUEST_TV(33554432), - RECENT_VIEW(67108864), - WATCHLIST_VIEW(134217728), - MANAGE_BLACKLIST(268435456), - VIEW_BLACKLIST(1073741824), + AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST), + AUTO_REQUEST_TV(33554432, AUTO_REQUEST), + RECENT_VIEW(67108864, MANAGE_REQUESTS), + WATCHLIST_VIEW(134217728, MANAGE_REQUESTS), + MANAGE_BLOCKLIST(268435456), + VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST), ; internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag @@ -48,5 +49,8 @@ enum class SeerrPermission( * Check whether the user has the given permissions (or is an admin) */ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { - return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions) + if (this?.permissions == null) return false + return permission.hasPermission(permissions) || + permission.parents.any { hasPermission(it) } || + SeerrPermission.ADMIN.hasPermission(permissions) } 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 8149f8b6..43d2d53a 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 @@ -53,7 +53,10 @@ sealed interface AppPreference<Pref, T> { value: T?, ): String? = null - fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid + fun validate( + prefs: Pref, + value: T, + ): PreferenceValidation = PreferenceValidation.Valid companion object { val SkipForward = @@ -215,15 +218,16 @@ sealed interface AppPreference<Pref, T> { ) val HomeClickToPlay = - AppSwitchPreference<AppPreferences>( + AppChoicePreference<AppPreferences, Boolean>( title = R.string.continue_watching_click_behavior, defaultValue = false, getter = { it.homePagePreferences.clickToPlay }, setter = { prefs, value -> prefs.updateHomePagePreferences { clickToPlay = value } }, - summaryOn = R.string.continue_watching_click_summary_on, - summaryOff = R.string.continue_watching_click_summary_off, + displayValues = R.array.home_click_to_play_options, + indexToValue = { it != 0 }, + valueToIndex = { if (it) 1 else 0 }, ) val PlayThemeMusic = @@ -391,18 +395,25 @@ sealed interface AppPreference<Pref, T> { defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { ac3Supported = value } + if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false } + prefs.updatePlaybackOverrides { + ac3Supported = value + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + val DownMixStereo = AppSwitchPreference<AppPreferences>( title = R.string.downmix_stereo, defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { downmixStereo = value } + if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false } + prefs.updatePlaybackOverrides { + downmixStereo = value + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -572,6 +583,13 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) + val UserProfileSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.override_user_profile_settings, + destination = Destination.UserAppPreferences, + summary = R.string.override_user_profile_settings_summary, + ) + val SkipIntros = AppChoicePreference<AppPreferences, SkipSegmentBehavior>( title = R.string.skip_intro_behavior, @@ -644,6 +662,20 @@ sealed interface AppPreference<Pref, T> { destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS), ) + val DpadSeekModePref = + AppChoicePreference<AppPreferences, DpadSeekMode>( + title = R.string.d_pad_seek_mode_title, + defaultValue = DpadSeekMode.SEEKBAR_MINIMAL, + getter = { it.playbackPreferences.dpadSeekMode }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { dpadSeekMode = value } + }, + displayValues = R.array.dpad_seek_mode_options, + indexToValue = { DpadSeekMode.forNumber(it) }, + valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 }, + subtitles = R.array.dpad_seek_mode_summaries, + ) + val GlobalContentScale = AppChoicePreference<AppPreferences, PrefContentScale>( title = R.string.global_content_scale, @@ -1106,6 +1138,7 @@ val basicPreferences = AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, AppPreference.UserInterfaceLanguage, + AppPreference.UserProfileSettings, ), ), PreferenceGroup( @@ -1208,6 +1241,7 @@ val advancedPreferences = AppPreference.CinemaMode, AppPreference.GlobalContentScale, AppPreference.SkipSegments, + AppPreference.DpadSeekModePref, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, AppPreference.ResolutionSwitching, @@ -1266,8 +1300,16 @@ val advancedPreferences = AppPreference.DebugLogging, AppPreference.ImageDiskCacheSize, AppPreference.ClearImageCache, + ExperimentalPreference.Enable, AppPreference.OssLicenseInfo, ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.experimentalPreferences.enabled }, + preferences = listOf(ExperimentalPreference.ExperimentalSettings), + ), + ), ), ) } @@ -1296,6 +1338,14 @@ val screensaverPreferences = ScreensaverPreference.Start, ), ), + PreferenceGroup( + title = R.string.dim_screen, + preferences = + listOf( + ScreensaverPreference.DimToggle, + ScreensaverPreference.DimPercentage, + ), + ), ) data class AppSwitchPreference<Pref>( @@ -1303,11 +1353,16 @@ data class AppSwitchPreference<Pref>( override val defaultValue: Boolean, override val getter: (prefs: Pref) -> Boolean, override val setter: (prefs: Pref, value: Boolean) -> Pref, - val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, + val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid }, @param:StringRes val summary: Int? = null, @param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOff: Int? = null, ) : AppPreference<Pref, Boolean> { + override fun validate( + prefs: Pref, + value: Boolean, + ): PreferenceValidation = validator.invoke(prefs, value) + override fun summary( context: Context, value: Boolean?, 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 ddac936a..c6dcd8ec 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 @@ -55,6 +55,7 @@ class AppPreferencesSerializer AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue cinemaMode = AppPreference.CinemaMode.defaultValue + dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue overrides = PlaybackOverrides @@ -174,6 +175,16 @@ class AppPreferencesSerializer showLyrics = true showAlbumArt = true }.build() + + experimentalPreferences = + ExperimentalPreferences + .newBuilder() + .apply { + enabled = false + videoTunnelingEnabled = false + preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue + disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -252,6 +263,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder musicPreferences = musicPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences = + update { + experimentalPreferences = experimentalPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt new file mode 100644 index 00000000..f18df846 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -0,0 +1,96 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation + +object ExperimentalPreference { + val Enable = + AppSwitchPreference<AppPreferences>( + title = R.string.experimental_settings, + defaultValue = false, + getter = { it.experimentalPreferences.enabled }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { enabled = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val ExperimentalSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.experimental_settings, + destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL), + ) + + val VideoTunneling = + AppSwitchPreference<AppPreferences>( + title = R.string.video_tunneling, + defaultValue = false, + getter = { it.experimentalPreferences.videoTunnelingEnabled }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { videoTunnelingEnabled = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val PreferAc3ForSurround = + AppSwitchPreference<AppPreferences>( + title = R.string.prefer_ac3_for_surround, + defaultValue = false, + getter = { it.experimentalPreferences.preferAc3Surround }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { + preferAc3Surround = value + } + }, + summary = R.string.prefer_ac3_for_surround_summary, + validator = { prefs, value -> + prefs.playbackPreferences.overrides.let { + if (value && !it.ac3Supported) { + PreferenceValidation.Invalid("AC3 support is not enabled") + } else if (value && it.downmixStereo) { + PreferenceValidation.Invalid("Always downmixing to stereo") + } else { + PreferenceValidation.Valid + } + } + }, + ) + + val DisableAudioOffload = + AppSwitchPreference<AppPreferences>( + title = R.string.disable_audio_offload, + defaultValue = false, + getter = { it.experimentalPreferences.disableAudioOffload }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { disableAudioOffload = value } + }, + summary = R.string.disable_audio_offload_summary, + ) +} + +val experimentalPreferences = + buildList { + add( + PreferenceGroup( + title = R.string.experimental_settings, + preferences = + listOf( + ExperimentalPreference.VideoTunneling, + ExperimentalPreference.PreferAc3ForSurround, + ExperimentalPreference.DisableAudioOffload, + ), + ), + ) + } + +/** + * Get a value from [ExperimentalPreference] or null if not enabled + */ +fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null + +fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt index 0000878b..478bffb6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/Extensions.kt @@ -1,5 +1,19 @@ package com.github.damontecres.wholphin.preferences +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration? } else { null } + +/** + * Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa + */ +@Composable +fun Modifier.lazyListWrapScrolling( + state: LazyListState, + focused: Boolean, + isFirst: Boolean, + isLast: Boolean, + firstFocusRequester: FocusRequester, + lastFocusRequester: FocusRequester, +): Modifier { + val scope = rememberCoroutineScope() + return this + .ifElse( + isFirst, + Modifier + .focusRequester(firstFocusRequester) + .onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1) + lastFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + isLast, + Modifier + .focusRequester(lastFocusRequester) + .onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + state.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ) +} 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 index 1e1ad91c..04fb4a63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -176,6 +176,37 @@ object ScreensaverPreference { }, ) + val DimToggle = + AppSwitchPreference<AppPreferences>( + title = R.string.dim_after_delay, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.dimEnabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { dimEnabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val DimPercentage = + AppSliderPreference<AppPreferences>( + title = R.string.dim_percentage, + defaultValue = 75, + min = 0, + max = 95, + interval = 5, + getter = { + it.interfacePreferences.screensaverPreference.dimPercent + .toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { dimPercent = value.toInt() } + }, + summarizer = { value -> + value?.let { "$value%" } + }, + ) + val Start = AppClickablePreference<AppPreferences>( title = R.string.start_screensaver, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt index 35da376a..c90cd00a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.preferences +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.UserConfiguration @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, + val userPreferences: JellyfinUserPreferences?, ) val DefaultUserConfiguration = diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt new file mode 100644 index 00000000..31d0e98c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup + +/** + * Represents settings from the user's profile on the server that may be overridden within the app + */ +object UserProfileSettings { + /** + * Special value that means the preferred language should be taken from the user's profile on the server + */ + const val USE_USER_PROFILE = "" + + /** + * Special value that means the user has no preferred language + */ + const val PREFER_ANY_LANGUAGE = "_any-language" + + val PreferredAudioLang = + AppClickablePreference<JellyfinUserPreferences>( + title = R.string.preferred_audio_language, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val PreferredSubtitleLang = + AppClickablePreference<JellyfinUserPreferences>( + title = R.string.preferred_subtitle_language, + summary = null, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val SubtitleModePref = + AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>( + title = R.string.subtitle_mode, + defaultValue = SubtitleModePreference.USE_USER_PROFILE, + getter = { it.subtitleMode }, + setter = { prefs, value -> + prefs.copy(subtitleMode = value) + }, + displayValues = R.array.subtitle_mode_options, + indexToValue = { SubtitleModePreference.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val Preferences = + listOf( + PreferenceGroup( + title = R.string.profile_specific_settings_from_server, + preferences = + listOf( + PreferredAudioLang, + PreferredSubtitleLang, + SubtitleModePref, + ), + ), + ) +} + +enum class SubtitleModePreference { + USE_USER_PROFILE, + DEFAULT, + SMART, + ONLY_FORCED, + ALWAYS, + NONE, +} 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 281eddc2..4910110a 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 @@ -6,12 +6,18 @@ import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreference 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.updateExperimentalPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences @@ -25,12 +31,16 @@ import com.github.damontecres.wholphin.preferences.updateSearchPreferences 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.ui.preferences.subtitle.shouldEnableSeparateHdrToggle import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -44,6 +54,8 @@ class AppUpgradeHandler @param:ApplicationContext private val context: Context, private val appPreferences: DataStore<AppPreferences>, private val seerrServerDao: SeerrServerDao, + private val serverDao: JellyfinServerDao, + private val rememberedTabDao: RememberedTabDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -350,5 +362,85 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) { + Timber.i("Starting tab migration") + val users = mutableMapOf<UUID, JellyfinUser?>() + val tabs = + appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) -> + // "serverId_userId_itemId", itemId can have underscores + try { + val firstUnderScore = key.indexOf("_").takeIf { it >= 1 } + if (firstUnderScore != null) { + val secondUnderscore = + key + .indexOf("_", startIndex = firstUnderScore + 1) + .takeIf { it >= 1 } + if (secondUnderscore != null) { + val serverId = key.substring(0, firstUnderScore).toUUIDOrNull() + val userId = + key + .substring(firstUnderScore + 1, secondUnderscore) + .toUUIDOrNull() + val itemId = key.substring(secondUnderscore + 1, key.length) + if (userId != null && serverId != null) { + return@mapNotNull users + .getOrPut(userId) { + serverDao.getUser(serverId, userId) + }?.let { + RememberedTab(it.rowId, itemId, tabIndex) + } + } + } + } + } catch (ex: Exception) { + Timber.w(ex, "Error parsing '%s'", key) + } + null + } + Timber.v("Migrating %s tabs", tabs.size) + try { + if (tabs.isNotEmpty()) { + rememberedTabDao.saveAll(tabs) + } + appPreferences.updateData { + it.updateInterfacePreferences { + clearRememberedTabs() + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error saving migrated tabs") + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.2-8-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + useSeparateHdr = it.interfacePreferences.shouldEnableSeparateHdrToggle() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.6-0-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + dimPercent = ScreensaverPreference.DimPercentage.defaultValue.toInt() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.6-7-g0"))) { + // preferAc3Surround was mistaken enabled by default, reset it only if the user hasn't enabled experimental settings + appPreferences.updateData { + if (!it.experimentalPreferences.enabled) { + it.updateExperimentalPreferences { + preferAc3Surround = + ExperimentalPreference.PreferAc3ForSurround.defaultValue + } + } else { + it + } + } + } } } 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 1bf34929..8025a6f6 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 @@ -18,8 +18,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -55,7 +55,7 @@ class BackdropService * Update the backdrop to use the specified item */ suspend fun submit(item: BaseItem) = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val imageUrl = if (item.type == BaseItemKind.GENRE) { item.imageUrlOverride @@ -76,7 +76,7 @@ class BackdropService suspend fun submit( itemId: String, imageUrl: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { _backdropFlow.update { it.copy( @@ -132,7 +132,7 @@ class BackdropService } suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { if (imageUrl.isNullOrBlank()) { return@withContext ExtractedColors.DEFAULT } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index 382ac22e..ceb84695 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.google.common.cache.CacheBuilder import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -56,7 +56,7 @@ class DatePlayedService adjacentTo = item.id, limit = 1, ) - return scope.async(Dispatchers.IO) { + return scope.async(WholphinDispatchers.IO) { val premiereDate = item.data.premiereDate try { val result = @@ -92,7 +92,7 @@ class DatePlayedService * the previous episode's last played timestamp, and the episode's premiere date */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val seriesId = item.data.seriesId return@withContext if (seriesId != null) { datePlayedCache diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 4405092e..29df802c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -1,12 +1,15 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode -import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -34,20 +37,17 @@ class DeviceProfileService private var deviceProfile: DeviceProfile? = null suspend fun getOrCreateDeviceProfile( - prefs: PlaybackPreferences, + appPrefs: AppPreferences, serverVersion: ServerVersion?, ): DeviceProfile = - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { + val prefs = appPrefs.playbackPreferences mutex.withLock { val newConfig = DeviceProfileConfiguration( maxBitrate = prefs.maxBitrate.toInt(), - isAC3Enabled = prefs.overrides.ac3Supported, - downMixAudio = prefs.overrides.downmixStereo, - assPlaybackMode = prefs.overrides.assPlaybackMode, - pgsDirectPlay = prefs.overrides.directPlayPgs, - dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, - decodeAv1 = prefs.overrides.decodeAv1, + overrides = prefs.overrides, + experimental = appPrefs.experimentalPreferences, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -57,12 +57,13 @@ class DeviceProfileService createDeviceProfile( mediaTest = mediaCodecCapabilitiesTest, maxBitrate = newConfig.maxBitrate, - isAC3Enabled = newConfig.isAC3Enabled, - downMixAudio = newConfig.downMixAudio, - assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, - pgsDirectPlay = newConfig.pgsDirectPlay, - dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, + isAC3Enabled = newConfig.overrides.ac3Supported, + downMixAudio = newConfig.overrides.downmixStereo, + assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, + pgsDirectPlay = newConfig.overrides.directPlayPgs, + dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, + preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -76,11 +77,7 @@ class DeviceProfileService */ data class DeviceProfileConfiguration( val maxBitrate: Int, - val isAC3Enabled: Boolean, - val downMixAudio: Boolean, - val assPlaybackMode: AssPlaybackMode, - val pgsDirectPlay: Boolean, - val dolbyVisionELDirectPlay: Boolean, - val decodeAv1: Boolean, + val overrides: PlaybackOverrides, + val experimental: ExperimentalPreferences, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt index e7770ff5..80a4e5ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -5,6 +5,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.pluralRes +import com.github.damontecres.wholphin.data.stringRes +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -48,9 +50,10 @@ class ExtrasService if (items.size == 1) { val item = items.first() val title = - item.title ?: context.resources.getQuantityString(type.pluralRes, 1) + item.title.takeIf { it.isNotNullOrBlank() } + ?: context.resources.getQuantityString(type.pluralRes, 1) val subtitle = - if (item.title == null) { + if (item.title.isNotNullOrBlank()) { context.resources.getQuantityString(type.pluralRes, 1) } else { null @@ -64,14 +67,14 @@ class ExtrasService imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), ) } else if (items.size > 1) { - val title = - context.resources.getQuantityString(type.pluralRes, items.size) + val title = context.resources.getString(type.stringRes) val subtitle = context.resources.getQuantityString( R.plurals.items, items.size, items.size, ) + val itemForImage = items.random() ExtrasItem.Group( parentId = itemId, type = type, @@ -81,9 +84,10 @@ class ExtrasService isPlayed = items.all { it.played }, imageUrl = imageUrlService.getItemImageUrl( - items.random(), + itemForImage, ImageType.PRIMARY, ), + aspectRatio = itemForImage.aspectRatio, ) } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt new file mode 100644 index 00000000..52734f20 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -0,0 +1,270 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter +import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterBy +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.showToast +import com.mayakapps.kache.InMemoryKache +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.filterApi +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.studiosApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +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 timber.log.Timber +import java.util.TreeSet +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +/** + * Get the possible values for filters in a library + */ +@Singleton +class FilterOptionCache + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val seerrService: SeerrService, + ) { + private val cache = + InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) { + creationScope = ioCoroutineScope + expireAfterWriteDuration = 1.hours + } + + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + parentId: UUID?, + filterOption: FilterBy<*, *>, + ): List<FilterValueOption> { + val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption) + return try { + cache + .getOrPut(cacheKey) { (userId, parentId, filterOption) -> + getFilterOptionValues(userId, parentId, filterOption) + }.orEmpty() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching options for %s", filterOption) + showToast(context, "Error occurred: ${ex.localizedMessage}") + emptyList() + } + } + + private suspend fun getFilterOptionValues( + userId: UUID?, + parentId: UUID?, + filterOption: FilterBy<*, *>, + ) = when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + StudioFilter -> { + api.studiosApi + .getStudios( + parentId = parentId, + userId = userId, + includeItemTypes = listOf(BaseItemKind.SERIES), + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + val ratings = + api.localizationApi + .getParentalRatings() + .content + .associate { + it.name to it.value + } + api.filterApi + .getQueryFiltersLegacy( + parentId = parentId, + userId = userId, + ).content.officialRatings + ?.map { r -> + val value = ratings[r] ?: 0 + FilterValueOption(r, value) + }?.sortedBy { it.value as Int } + .orEmpty() + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet<Int>() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + + is DiscoverFilterBy -> { + getDiscoverFilterOptionValues(filterOption) + } + } + + private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List<FilterValueOption> { + if (seerrService.active.firstOrNull() != true) { + return emptyList() + } + return when (filterOption) { + DiscoverMovieGenreFilter -> { + seerrService.api.searchApi + .discoverGenresliderMovieGet() + .filter { it.name != null && it.id != null } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvGenreFilter -> { + seerrService.api.searchApi + .discoverGenresliderTvGet() + .filter { it.name != null && it.id != null } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverMovieStudiosFilter -> { + // TODO region + seerrService.api.otherApi + .watchprovidersMoviesGet("US") + .filter { it.name != null && it.id != null } + // TODO not what this is for +// .sortedBy { it.displayPriority } + .map { + // TODO logo? + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvStudiosFilter -> { + // TODO region + seerrService.api.otherApi + .watchprovidersTvGet("US") + .filter { it.name != null && it.id != null } +// .sortedBy { it.displayPriority } + .map { + FilterValueOption(it.name!!, it.id!!) + } + } + + DiscoverTvStatusFilter -> { + TvShowStatus.entries.map { + FilterValueOption(it.value, it) + } + } + + DiscoverMovieContentRatingFilter -> { + listOf("NR", "G", "PG", "PG-13", "R", "NC-17") + .map { FilterValueOption(it, it) } + } + + DiscoverTvContentRatingFilter -> { + listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA") + .map { FilterValueOption(it, it) } + } + + DiscoverRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + } + + private data class FilterOptionCacheKey( + val userId: UUID?, + val parentId: UUID?, + val filterOption: FilterBy<*, *>, + ) + } 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 index 4288e337..3371f45e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -7,16 +7,19 @@ 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.HomeRowViewOptions 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.data.model.createStudioDestination 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.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.HomeItemFields +import com.github.damontecres.wholphin.ui.ProgramItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.formatTypeName 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 @@ -26,15 +29,16 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetProgramsDtoHandler import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState 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 @@ -53,20 +57,22 @@ import org.jellyfin.sdk.api.client.ApiClient 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.DateTime 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.GetProgramsDto import org.jellyfin.sdk.model.api.ImageType +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.UserDto +import org.jellyfin.sdk.model.api.request.GetArtistsRequest 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.GetLiveTvChannelsRequest 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 org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber @@ -239,7 +245,7 @@ class HomeSettingsService settings.rows.mapIndexed { index, config -> resolve(index, config) } - HomePageResolvedSettings(resolvedRows) + HomePageResolvedSettings(userId, resolvedRows) } else { createDefault(userId) } @@ -250,12 +256,15 @@ class HomeSettingsService /** * Resolve the settings and set them to be the current settings */ - suspend fun updateCurrent(settings: HomePageSettings) { + suspend fun updateCurrent( + userId: UUID, + settings: HomePageSettings, + ) { val resolvedRows = settings.rows.mapIndexed { index, config -> resolve(index, config) } - val resolvedSettings = HomePageResolvedSettings(resolvedRows) + val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) currentSettings.update { resolvedSettings } } @@ -275,20 +284,25 @@ class HomeSettingsService val includedIds = libraries - .mapIndexed { index, it -> - val parentId = it.itemId - val title = getRecentlyAddedTitle(it) - if (it.collectionType == CollectionType.LIVETV) { + .mapIndexed { index, library -> + val parentId = library.itemId + val title = getRecentlyAddedTitle(library.name) + if (library.collectionType == CollectionType.LIVETV) { HomeRowConfigDisplay( id = index, - title = ResStringProvider(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { + val viewOptions = viewOptionsForCollectionType(library.collectionType) HomeRowConfigDisplay( id = index, title = title, - config = HomeRowConfig.RecentlyAdded(parentId), + config = + HomeRowConfig.RecentlyAdded( + parentId = parentId, + viewOptions = viewOptions, + ), ) } } @@ -301,7 +315,7 @@ class HomeSettingsService ), ) val rowConfig = continueWatchingRow + includedIds - return HomePageResolvedSettings(rowConfig) + return HomePageResolvedSettings(userId, rowConfig) } /** @@ -318,12 +332,10 @@ class HomeSettingsService 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 + navDrawerService + .getAllUserLibraries(userId, userDto.tvAccess) + .filterNot { + it.itemId in config.latestItemsExcludes } return if (customPrefs.isNotEmpty()) { @@ -367,7 +379,7 @@ class HomeSettingsService if (userDto.tvAccess) { HomeRowConfigDisplay( id = id++, - title = ResStringProvider(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config = HomeRowConfig.TvPrograms(), ) } else { @@ -396,15 +408,21 @@ class HomeSettingsService } } if (sectionType == HomeSectionType.LATEST_MEDIA) { - libraries.map { + libraries.map { library -> + val viewOptions = + viewOptionsForCollectionType(library.collectionType) HomeRowConfigDisplay( id = id++, title = ResArgStringProvider( R.string.recently_added_in, - it.name ?: "", + library.name ?: "", + ), + config = + HomeRowConfig.RecentlyAdded( + parentId = library.itemId, + viewOptions = viewOptions, ), - config = HomeRowConfig.RecentlyAdded(it.id), ) } } else if (config != null) { @@ -413,7 +431,7 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(rowConfigs) + HomePageResolvedSettings(userId, rowConfigs) } else { null } @@ -503,8 +521,8 @@ class HomeSettingsService is HomeRowConfig.Favorite -> { val name = ResProviderStringProvider( - R.string.favorite_items, - ResStringProvider(favoriteOptions[config.kind]!!), + R.string.favorite_items_title, + ResStringProvider(formatTypeName(config.kind)), ) HomeRowConfigDisplay(id, name, config) } @@ -520,7 +538,7 @@ class HomeSettingsService is HomeRowConfig.TvPrograms -> { HomeRowConfigDisplay( id = id, - title = ResStringProvider(R.string.live_tv), + title = ResStringProvider(R.string.watch_live), config, ) } @@ -550,8 +568,10 @@ class HomeSettingsService ): StringProvider = try { api.userLibraryApi - .getItem(itemId = itemId) - .content.name + .getItem( + userId = serverRepository.currentUser?.id, + itemId = itemId, + ).content.name ?.let { if (stringRes == null) { StringStringProvider(it) @@ -592,6 +612,7 @@ class HomeSettingsService items = resume, viewOptions = row.viewOptions, rowType = row, + showViewMore = resume.size >= limit, ) } @@ -611,6 +632,7 @@ class HomeSettingsService items = nextUp, viewOptions = row.viewOptions, rowType = row, + showViewMore = nextUp.size >= limit, ) } @@ -631,17 +653,14 @@ class HomeSettingsService prefs.maxDaysNextUp, row.viewOptions.useSeries, ) + val combined = latestNextUpService.buildCombined(resume, nextUp) Success( title = ResStringProvider(R.string.continue_watching), - items = - latestNextUpService - .buildCombined( - resume, - nextUp, - ).take(limit), + items = combined.take(limit), viewOptions = row.viewOptions, rowType = row, + showViewMore = combined.size >= limit, ) } @@ -693,6 +712,9 @@ class HomeSettingsService listOf(it) } }, + collectionType = + library?.collectionType + ?: CollectionType.UNKNOWN, ), ) } @@ -702,6 +724,7 @@ class HomeSettingsService genres, viewOptions = row.viewOptions, rowType = row, + showViewMore = genres.size >= limit, ) } @@ -753,17 +776,16 @@ class HomeSettingsService title, studios, viewOptions = row.viewOptions, + showViewMore = studios.size >= limit, ) } is HomeRowConfig.RecentlyAdded -> { - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(library) + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = getRecentlyAddedTitle(library?.name) val request = GetLatestMediaRequest( - fields = SlimItemFields, + fields = library.itemFields, imageTypeLimit = 1, parentId = row.parentId, groupItems = true, @@ -781,18 +803,16 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } latest } is HomeRowConfig.RecentlyReleased -> { - val name = - libraries - .firstOrNull { it.itemId == row.parentId } - ?.name + val library = libraries.firstOrNull { it.itemId == row.parentId } val title = - name?.let { + library?.name?.let { ResArgStringProvider(R.string.recently_released_in, it) } ?: ResStringProvider(R.string.recently_released) val request = @@ -811,7 +831,7 @@ class HomeSettingsService SortOrder.ASCENDING, SortOrder.DESCENDING, ), - fields = DefaultItemFields, + fields = library.itemFields, recursive = true, maxPremiereDate = LocalDateTime.now(), isUnaired = false, @@ -835,11 +855,13 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } is HomeRowConfig.ByParent -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } val request = GetItemsRequest( userId = userDto.id, @@ -848,27 +870,44 @@ class HomeSettingsService sortBy = row.sort?.let { buildList { - add(it.sort) - if (it.sort != ItemSortBy.SORT_NAME) { + if (it.sort == ItemSortBy.RANDOM) { add(ItemSortBy.SORT_NAME) + add(ItemSortBy.RANDOM) + } else { + add(it.sort) + if (it.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } } } }, sortOrder = row.sort?.let { buildList { - add(it.direction) - if (it.sort != ItemSortBy.SORT_NAME) { + if (it.sort == ItemSortBy.RANDOM) { add(SortOrder.ASCENDING) + add(it.direction) + } else { + add(it.direction) + if (it.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } } } }, limit = limit, - fields = DefaultItemFields, + fields = library.itemFields, ) + // Not using getItemName because we want to throw the 404 val title = - getItemName(null, row.parentId, ResStringProvider(R.string.collection)) + api.userLibraryApi + .getItem( + userId = serverRepository.currentUser?.id, + itemId = row.parentId, + ).content.name + ?.let { StringStringProvider(it) } + ?: ResStringProvider(R.string.collection) if (usePaging) { ApiRequestPager( api, @@ -888,6 +927,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -925,6 +965,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -932,61 +973,87 @@ class HomeSettingsService is HomeRowConfig.Favorite -> { val title = ResProviderStringProvider( - R.string.favorite_items, - ResStringProvider(favoriteOptions[row.kind]!!), + R.string.favorite_items_title, + ResStringProvider(formatTypeName(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, - ) + val resultList = + when (row.kind) { + BaseItemKind.PERSON -> { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + + GetPersonsHandler.execute(api, request).toBaseItems() + } + + BaseItemKind.MUSIC_ARTIST -> { + val request = + GetArtistsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetArtistsHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetArtistsHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + + else -> { + val fields = + if (row.kind == BaseItemKind.BOX_SET) { + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = fields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } } - } else { - val request = - GetItemsRequest( - userId = userDto.id, - recursive = true, - limit = limit, - fields = DefaultItemFields, - includeItemTypes = listOf(row.kind), - isFavorite = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - ) } + resultList.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) } } @@ -995,7 +1062,7 @@ class HomeSettingsService GetRecordingsRequest( userId = userDto.id, isInProgress = true, - fields = DefaultItemFields, + fields = HomeItemFields, limit = limit, enableImages = true, enableUserData = true, @@ -1020,41 +1087,53 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } is HomeRowConfig.TvPrograms -> { val request = - GetRecommendedProgramsRequest( + GetProgramsDto( userId = userDto.id, - fields = DefaultItemFields, + fields = ProgramItemFields, limit = limit, enableUserData = true, enableImages = true, enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), imageTypeLimit = 1, + isAiring = true, + minEndDate = DateTime.now().plusMinutes(1), ) - // paging not supported - api.liveTvApi - .getRecommendedPrograms(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - ResStringProvider(R.string.live_tv), - it, - row.viewOptions, - rowType = row, - ) - } + if (usePaging) { + ApiRequestPager( + api, + request, + GetProgramsDtoHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.watch_live), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } } is HomeRowConfig.TvChannels -> { val request = GetLiveTvChannelsRequest( userId = userDto.id, - fields = DefaultItemFields, + fields = HomeItemFields, limit = limit, enableImages = true, ) @@ -1076,6 +1155,7 @@ class HomeSettingsService it, row.viewOptions, rowType = row, + showViewMore = it.size >= limit, ) } } @@ -1108,6 +1188,7 @@ class HomeSettingsService suggestions.items, row.viewOptions, rowType = row, + showViewMore = suggestions.items.size >= limit, ) } @@ -1146,10 +1227,11 @@ data class HomeRowConfigDisplay( * @see HomePageSettings */ data class HomePageResolvedSettings( + val userId: UUID, val rows: List<HomeRowConfigDisplay>, ) { companion object { - val EMPTY = HomePageResolvedSettings(listOf()) + val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) } } @@ -1179,10 +1261,56 @@ class UnsupportedHomeSettingsVersionException( val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") -fun getRecentlyAddedTitle(library: Library?): StringProvider = - if (library?.isRecordingFolder == true) { - ResStringProvider(R.string.recently_recorded) - } else { - library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) } - ?: ResStringProvider(R.string.recently_added) +fun getRecentlyAddedTitle(name: String?): StringProvider = + name?.let { ResArgStringProvider(R.string.recently_added_in, it) } + ?: ResStringProvider(R.string.recently_added) + +private val Library?.itemFields: List<ItemFields> + get() = + if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { + // Get child count to show in the header + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + +private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) + +fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions = + when (collectionType) { + CollectionType.MUSIC, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.SQUARE, + ) + } + + CollectionType.PHOTOS, + CollectionType.HOMEVIDEOS, + CollectionType.MUSICVIDEOS, + CollectionType.TRAILERS, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } + + CollectionType.LIVETV, + -> { + HomeRowViewOptions.liveTvDefault + } + + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.BOXSETS, + CollectionType.BOOKS, + CollectionType.PLAYLISTS, + CollectionType.FOLDERS, + CollectionType.UNKNOWN, + null, + -> { + HomeRowViewOptions() + } } 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 1dd642b0..6b71d683 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 @@ -223,11 +223,11 @@ class ImageUrlService imageType = imageType, maxWidth = maxWidth, maxHeight = maxHeight, - width = width, - height = height, + width = width?.takeIf { it > 0 }, + height = height?.takeIf { it > 0 }, quality = quality, - fillWidth = fillWidth, - fillHeight = fillHeight, + fillWidth = fillWidth?.takeIf { it > 0 }, + fillHeight = fillHeight?.takeIf { it > 0 }, tag = tag, format = format, percentPlayed = percentPlayed, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt new file mode 100644 index 00000000..2dd0cde5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -0,0 +1,221 @@ +package com.github.damontecres.wholphin.services + +import android.app.SearchManager +import android.content.Intent +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.nav.Destination +import kotlinx.coroutines.flow.first +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.serializer.toUUIDOrNull +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class IntentService + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + suspend fun parseIntent(intent: Intent): IntentResult { + Timber.v("Parsing intent %s", intent) + Timber.v("Intent extras: %s", intent.extras) + Timber.v("Intent data: %s", intent.data) + val action = intent.action ?: intent.data?.host + if ( + intent.getStringParam(INTENT_ITEM_TYPE) == null && + (action == Intent.ACTION_MAIN || action.isNullOrBlank()) + ) { + // Normal app launch + return IntentResult.NoOp + } + + val result = prepare(intent) + if (result != null) { + return result + } + + // Existence of this key implies launching from Play Next channel + if (intent.getStringParam(INTENT_ITEM_TYPE) != null) { + return getDestinationFromChannel(intent)?.let { + IntentResult.Target( + destinations = listOf(it), + addHomeToBackStack = false, + ) + } ?: IntentResult.Error("Invalid parameters") + } + + if (action == Intent.ACTION_SEARCH || action == "search") { + val query = intent.getStringParam(SearchManager.QUERY) + return IntentResult.Target(listOf(Destination.Search(query ?: ""))) + } + + val itemId = intent.getStringParam("itemId")?.toUUIDOrNull() + val item = + itemId?.let { + try { + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + } catch (ex: Exception) { + Timber.w(ex, "Error fetching item %s", itemId) + return IntentResult.Error("Could not fetch item $itemId") + } + } + val itemDestination = item?.destination() + + val destinations = + when (action) { + Intent.ACTION_VIEW, "view" -> { + if (itemDestination != null) { + listOf(itemDestination) + } else { + // Will go to the home page + emptyList() + } + } + + ACTION_PLAYBACK, "play" -> { + if (itemId == null || itemDestination == null) { + return IntentResult.Error("Cannot start playback with no itemId") + } + val position = intent.getLongParam("position")?.coerceAtLeast(0) + val shuffle = intent.getBooleanExtra("shuffle", false) + + val playbackDestination = + Destination.Playback( + itemId = itemId, + positionMs = position ?: 0L, + shuffle = shuffle, + ) + + if (itemDestination is Destination.Playback) { + listOf(playbackDestination) + } else { + listOf( + itemDestination, + playbackDestination, + ) + } + } + + else -> { + return IntentResult.Error("Invalid action: ${intent.action}") + } + } + + return IntentResult.Target(destinations, true) + } + + internal suspend fun prepare(intent: Intent): IntentResult? { + val appPrefs = userPreferencesService.flow.first().appPreferences + + val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull() + val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() + return if (userId != null && serverId != null) { + Timber.v("Intent switches user") + val user = serverRepository.serverDao.getUser(serverId, userId) + if (user != null && !user.isProtected) { + serverRepository.restoreSession(serverId, userId) + ?: IntentResult.Error("Error restoring user") + null + } else { + IntentResult.Error("Cannot switch to specified user") + } + } else { + val profileProtected = + serverRepository.current.value + ?.user + ?.isProtected == true + if (appPrefs.signInAutomatically && !profileProtected) { + Timber.v("No current user, so restoring last") + val userId = appPrefs.currentUserId?.toUUIDOrNull() + val serverId = appPrefs.currentServerId?.toUUIDOrNull() + + if (userId != null && serverId != null) { + val current = + serverRepository.restoreSession(serverId, userId) + ?: return IntentResult.Error("Error restoring user") + if (current.user.isProtected) { + IntentResult.Error("Could not auto sign-in, user is protected") + } else { + null + } + } else { + IntentResult.Error("Could not auto sign-in, specify a server & user") + } + } else { + IntentResult.Error("Auto sign-in not enabled or user is protected") + } + } + } + + private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) + + private fun Intent.getLongParam(key: String) = + getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull() + + private fun getDestinationFromChannel(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + const val INTENT_SERVER_ID = "serverId" + const val INTENT_USER_ID = "userId" + + const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK" + } + } + +sealed interface IntentResult { + data class Error( + val message: String, + ) : IntentResult + + data object NoOp : IntentResult + + data class Target( + val destinations: List<Destination>, + val addHomeToBackStack: Boolean = true, + ) : IntentResult +} 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 2bc9943f..4ce6583f 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 @@ -8,8 +8,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.util.LocalDateTimeSerializer -import com.github.damontecres.wholphin.util.supportItemKinds -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore @@ -23,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest @@ -60,16 +60,8 @@ class LatestNextUpService userId = userId, fields = SlimItemFields, limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, + mediaTypes = listOf(MediaType.VIDEO), + excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, enableTotalRecordCount = false, ) val items = @@ -143,14 +135,14 @@ class LatestNextUpService resume: List<BaseItem>, nextUp: List<BaseItem>, ): List<BaseItem> = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val start = System.currentTimeMillis() val semaphore = Semaphore(3) val deferred = nextUp .filter { it.data.seriesId != null } .map { item -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { try { semaphore.withPermit { datePlayedService.getLastPlayed(item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 04706b94..01ed9dd2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -16,12 +16,12 @@ import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedInject 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 org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -92,7 +92,7 @@ class LatestNextUpSchedulerService ) // Exposed for testing - internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO init { serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt new file mode 100644 index 00000000..d89916f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt @@ -0,0 +1,94 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.model.api.TimerInfoDto +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Functions for recording live tv programs + */ +@Singleton +class LiveTvService + @Inject + constructor( + private val api: ApiClient, + ) { + /** + * Fetch a tv program by ID + */ + suspend fun fetchProgramForDialog(programId: UUID): BaseItem { + val result = + api.liveTvApi + .getProgram(programId.toServerString()) + .content + .let { BaseItem(it) } + return result + } + + /** + * Cancel a recording by [timerId] + * + * @param series whether the timerId is a series recording or not + * @param timerId the recording ID + */ + suspend fun cancelRecording( + series: Boolean, + timerId: String?, + ): Boolean = + if (timerId != null) { + if (series) { + api.liveTvApi.cancelSeriesTimer(timerId) + } else { + api.liveTvApi.cancelTimer(timerId) + } + true + } else { + false + } + + /** + * Record tv program + * + * @param programId the tv program ID + * @param series whether to set up series recording or just a single recording + */ + suspend fun record( + programId: UUID, + series: Boolean, + ) { + val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) + if (series) { + api.liveTvApi.createSeriesTimer(d) + } else { + val payload = + TimerInfoDto( + id = d.id, + type = d.type, + serverId = d.serverId, + externalId = d.externalId, + channelId = d.channelId, + externalChannelId = d.externalChannelId, + channelName = d.channelName, + programId = d.programId, + externalProgramId = d.externalProgramId, + name = d.name, + overview = d.overview, + startDate = d.startDate, + endDate = d.endDate, + serviceName = d.serviceName, + priority = d.priority, + prePaddingSeconds = d.prePaddingSeconds, + postPaddingSeconds = d.postPaddingSeconds, + isPrePaddingRequired = d.isPrePaddingRequired, + isPostPaddingRequired = d.isPostPaddingRequired, + keepUntil = d.keepUntil, + ) + api.liveTvApi.createTimer(payload) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index c339a8dd..ec6e9d2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -13,8 +13,11 @@ import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.seekBack @@ -24,10 +27,11 @@ import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -44,6 +49,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.sockets.subscribe 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.PlayMethod import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage @@ -72,6 +78,7 @@ class MusicService private val playerFactory: PlayerFactory, private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, + private val userPreferencesService: UserPreferencesService, ) { private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow<MusicServiceState> = _state @@ -79,8 +86,15 @@ class MusicService private val audioFormats by lazy { listOf(*supportedAudioCodecs) } val player: Player by lazy { + val disableAudioOffload = + runBlocking { + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .get { disableAudioOffload } ?: false + } playerFactory - .createAudioPlayer() + .createAudioPlayer(disableAudioOffload = disableAudioOffload) .also { it.addListener(MusicPlayerListener(it, _state)) } @@ -92,6 +106,12 @@ class MusicService private var activityTracker: TrackActivityPlaybackListener? = null private var websocketJob: Job? = null + private suspend fun preferAc3Surround() = + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .enabled { preferAc3Surround } + /** * Start music playback * @@ -162,7 +182,7 @@ class MusicService userId = serverRepository.currentUser?.id, itemId = itemId, limit = 200, - fields = DefaultItemFields, + fields = DetailItemFields, ).content.items .map { BaseItem(it, false) } setQueue(items, false) @@ -177,9 +197,9 @@ class MusicService items: BlockingList<BaseItem?>, startIndex: Int, shuffled: Boolean, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.setMediaItems(emptyList()) player.shuffleModeEnabled = shuffled } @@ -195,11 +215,12 @@ class MusicService shuffled: Boolean, ) { Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled) + val preferAc3Surround = preferAc3Surround() val mediaItems = items .filter { it.type == BaseItemKind.AUDIO } - .map(::convert) - withContext(Dispatchers.Main) { + .map { convert(it, preferAc3Surround) } + withContext(WholphinDispatchers.Main) { player.setMediaItems(mediaItems) player.shuffleModeEnabled = shuffled updateQueueSize() @@ -215,8 +236,9 @@ class MusicService index: Int? = null, ) { if (item.type == BaseItemKind.AUDIO) { - val mediaItem = convert(item) - withContext(Dispatchers.Main) { + val preferAc3Surround = preferAc3Surround() + val mediaItem = convert(item, preferAc3Surround) + withContext(WholphinDispatchers.Main) { if (index != null) { player.addMediaItem(index, mediaItem) } else { @@ -228,6 +250,8 @@ class MusicService start() } } + } else { + Timber.w("Tried to add non-audio type %s: %s", item.type, item.id) } } @@ -240,6 +264,7 @@ class MusicService list: BlockingList<BaseItem?>, startIndex: Int, ) = loading { + val preferAc3Surround = preferAc3Surround() var remaining = startIndex list.indices .chunked(25) @@ -250,7 +275,7 @@ class MusicService list .getBlocking(it) ?.takeIf { it.type == BaseItemKind.AUDIO } - ?.let(::convert) + ?.let { convert(it, preferAc3Surround) } } else { Timber.v("Skipping $remaining") remaining-- @@ -266,12 +291,36 @@ class MusicService /** * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag */ - private fun convert(audio: BaseItem): MediaItem { + private fun convert( + audio: BaseItem, + preferAc3Surround: Boolean, + ): MediaItem { + val needsAc3Transcode = + preferAc3Surround && + audio.data.mediaSources + ?.firstOrNull() + ?.mediaStreams + ?.any { stream -> + stream.type == MediaStreamType.AUDIO && + stream.channels.gt(2) + stream.codec != Codec.Audio.AC3 + } == true val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - itemId = audio.id, - container = audioFormats, - ) + if (needsAc3Transcode) { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = listOf("mka"), + transcodingContainer = "mka", + maxAudioChannels = 6, + transcodingAudioChannels = 6, + audioCodec = Codec.Audio.AC3, + ) + } else { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = audioFormats, + ) + } Timber.i("url=%s", url) val imageUrl = audio.data.albumId?.let { albumId -> @@ -293,7 +342,7 @@ class MusicService */ private suspend fun updateQueueSize() { // val ids = -// withContext(Dispatchers.Default) { +// withContext(WholphinDispatchers.Default) { // (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() } // } val timeline = onMain { player.currentTimeline } @@ -304,7 +353,7 @@ class MusicService timeline.getWindow(it, window) window.mediaItem.mediaId.toUUID() }.toSet() - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { _state.update { it.copy( queueVersion = it.queueVersion + 1, @@ -321,7 +370,7 @@ class MusicService suspend fun moveQueue( index: Int, direction: MoveDirection, - ) = withContext(Dispatchers.Main) { + ) = withContext(WholphinDispatchers.Main) { player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1) updateQueueSize() } @@ -332,7 +381,7 @@ class MusicService suspend fun moveQueue( index: Int, newIndex: Int, - ) = withContext(Dispatchers.Main) { + ) = withContext(WholphinDispatchers.Main) { player.moveMediaItem(index, newIndex) updateQueueSize() } @@ -352,7 +401,7 @@ class MusicService * Play this item next after the current, ie add the item as the next index in the queue */ suspend fun playNext(song: BaseItem) { - val mediaItem = convert(song) + val mediaItem = convert(song, preferAc3Surround()) onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) if (player.mediaItemCount == 1) { @@ -385,7 +434,7 @@ class MusicService .subscribe<PlaystateMessage>() .onEach { message -> message.data?.let { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { when (it.command) { PlaystateCommand.STOP -> { stop() 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 index cbf2e955..6b8701ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -6,27 +6,27 @@ 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.collectLatestIn import com.github.damontecres.wholphin.ui.launchDefault 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.WholphinDispatchers import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest 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.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType @@ -52,35 +52,37 @@ class NavDrawerService private val seerrServerRepository: SeerrServerRepository, private val musicService: MusicService, ) { - private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + private val _state = MutableStateFlow(NavDrawerItemState()) val state: StateFlow<NavDrawerItemState> = _state init { // Handle updating the nav drawer when the user changes - serverRepository.currentUserFlow - .combine(serverRepository.currentUserDtoFlow) { 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(), - ) - } + combine( + serverRepository.currentUserFlow, + serverRepository.currentUserDtoFlow, + seerrServerRepository.active, + ) { user, userDto, discoverActive -> + Triple(user, userDto, discoverActive) + }.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) -> + Timber.d( + "User updated: user=%s, userDto=%s, discoverActive=%s", + user?.id, + userDto?.id, + discoverActive, + ) + try { if (user != null && userDto != null && user.id == userDto.id) { - updateNavDrawer(user, userDto) + updateNavDrawer(user, userDto, discoverActive) + } else { + _state.update { NavDrawerItemState() } } - }.catch { ex -> + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { Timber.e(ex, "Error updating nav drawer") showToast(context, "Error fetching user's views") - }.launchIn(coroutineScope) - - // Handle when the user has logged into a Seerr server - seerrServerRepository.active - .onEach { discoverActive -> - _state.update { it.copy(discoverEnabled = discoverActive) } - }.launchIn(coroutineScope) + } + } // Handle when music is actively playing or not coroutineScope.launchDefault { @@ -132,13 +134,22 @@ class NavDrawerService .content.items val recordingFolders = if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = userId) - .content.items - .map { it.id } - .toSet() + try { + api.liveTvApi + .getRecordingFolders(userId = userId) + .content.items + .map { it.id } + .toSet() + } catch (ex: InvalidStatusException) { + if (ex.status == 401 || ex.status == 403) { + Timber.w("Got HTTP %s querying for recording folders", ex.status) + emptySet() + } else { + throw ex + } + } } else { - setOf() + emptySet() } val libraries = userViews @@ -178,8 +189,13 @@ class NavDrawerService suspend fun updateNavDrawer( user: JellyfinUser, userDto: UserDto, + discoverActive: Boolean, ) { - val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + val builtins = + buildList { + add(NavDrawerItem.Favorites) + if (discoverActive) add(NavDrawerItem.Discover) + } val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) val libraries = allLibraries @@ -204,7 +220,7 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } } @@ -229,21 +245,18 @@ class NavDrawerService it.copy( items = items, moreItems = moreItems, + allLibraries = allLibraries, ) } } } data class NavDrawerItemState( - val items: List<NavDrawerItem>, - val moreItems: List<NavDrawerItem>, - val discoverEnabled: Boolean, - val nowPlayingEnabled: Boolean, - val nowPlayingTitle: String?, -) { - companion object { - val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null) - } -} + val items: List<NavDrawerItem> = emptyList(), + val moreItems: List<NavDrawerItem> = emptyList(), + val nowPlayingEnabled: Boolean = false, + val nowPlayingTitle: String? = null, + val allLibraries: List<Library> = emptyList(), +) val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 7f4886b4..cf4128fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -24,19 +24,21 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.session.MediaSession +import com.github.damontecres.wholphin.mpv.MpvPlayer +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.MediaExtensionStatus -import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.util.mpv.MpvPlayer +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.factory.AssRenderersFactory import io.github.peerless2012.ass.media.kt.withAssMkvSupport import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import timber.log.Timber @@ -60,9 +62,10 @@ class PlayerFactory suspend fun createVideoPlayer( backend: PlayerBackend, - prefs: PlaybackPreferences, + appPreferences: AppPreferences, ): PlayerCreation { - withContext(Dispatchers.Main) { + val prefs = appPreferences.playbackPreferences + withContext(WholphinDispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() @@ -104,6 +107,7 @@ class PlayerFactory WholphinRenderersFactory(context, decodeAv1) .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = if (useLibAss) { val renderType = @@ -128,7 +132,15 @@ class PlayerFactory extractorsFactory, ) } - val trackSelector = createTrackSelector() + val disableAudioOffload = + appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false + val tunneling = + appPreferences.experimentalPreferences.get { videoTunnelingEnabled } + val trackSelector = + createTrackSelector( + tunneling = tunneling, + disableAudioOffload = disableAudioOffload, + ) ExoPlayer .Builder(context) @@ -138,7 +150,7 @@ class PlayerFactory .build() .apply { assHandler?.init(this) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { setAudioAttributes( AudioAttributes .Builder() @@ -158,7 +170,10 @@ class PlayerFactory return PlayerCreation(newPlayer, assHandler) } - fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer { + fun createAudioPlayer( + disableAudioOffload: Boolean, + extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK, + ): ExoPlayer { val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -176,7 +191,7 @@ class PlayerFactory OkHttpDataSource.Factory(authOkHttpClient), extractorsFactory, ) - val trackSelector = createTrackSelector() + val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload) return ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) @@ -187,9 +202,10 @@ class PlayerFactory it.setAudioAttributes( AudioAttributes .Builder() + .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build(), - false, + true, ) } } @@ -199,18 +215,33 @@ class PlayerFactory .setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true) - private fun createTrackSelector() = - DefaultTrackSelector(context).apply { - setParameters( - buildUponParameters() - .setAudioOffloadPreferences( - AudioOffloadPreferences - .Builder() - .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) - .build(), - ), - ) - } + private fun createTrackSelector( + tunneling: Boolean? = null, + disableAudioOffload: Boolean = false, + ) = DefaultTrackSelector(context).apply { + val offloadMode = + if (disableAudioOffload) { + AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } else { + AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } + setParameters( + buildUponParameters() + .apply { + tunneling?.let { setTunnelingEnabled(tunneling) } + }.setAudioOffloadPreferences( + AudioOffloadPreferences + .Builder() + .setAudioOffloadMode(offloadMode) + .build(), + ), + ) + } + + fun createMediaSession(player: Player) = + MediaSession + .Builder(context, player) + .build() } val Player.isReleased: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index bf5a1269..b1a48753 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -8,21 +8,20 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.data.model.PlaylistItem -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -66,7 +65,7 @@ class PlaylistCreator GetEpisodesRequest( seriesId = seriesId, seasonId = seasonId, - fields = DefaultItemFields, + fields = DetailItemFields, startItemId = episodeId, sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, @@ -78,7 +77,7 @@ class PlaylistCreator .convertAndAddParts(false) val startIndex = episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 - return Playlist(episodes, startIndex) + return Playlist(episodes.subList(startIndex, episodes.size)) } /** @@ -95,7 +94,7 @@ class PlaylistCreator GetItemsRequest( userId = serverRepository.currentUser?.id, parentId = playlistId, - fields = DefaultItemFields, + fields = DetailItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, sortBy = listOf(sortAndDirection.sort), @@ -104,7 +103,7 @@ class PlaylistCreator ), ) val items = GetItemsRequestHandler.execute(api, request).content.items - return Playlist(items.convertAndAddParts(), 0) + return Playlist(items.convertAndAddParts()) } private suspend fun createFromCollection( @@ -136,7 +135,7 @@ class PlaylistCreator excludeItemIds = listOf(item.id), sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) }, sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) }, - fields = DefaultItemFields, + fields = DetailItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, excludeLocationTypes = listOf(LocationType.VIRTUAL), @@ -147,7 +146,7 @@ class PlaylistCreator .execute(api, request) .content.items .convertAndAddParts() - return Playlist(items, 0) + return Playlist(items) } /** @@ -202,28 +201,72 @@ class PlaylistCreator BaseItemKind.SEASON -> { val seriesId = item.seriesId if (seriesId != null) { - PlaylistCreationResult.Success( - createFromEpisode( - seriesId = seriesId, - seasonId = item.id, - episodeId = null, - shuffled = shuffled, - ), - ) + if (shuffled) { + api.tvShowsApi + .getEpisodes( + seriesId = seriesId, + seasonId = item.id, + limit = Playlist.MAX_SIZE, + sortBy = ItemSortBy.RANDOM, + fields = DetailItemFields, + ).content.items + .convertAndAddParts() + .let { + PlaylistCreationResult.Success(Playlist(it)) + } + } else { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = seriesId, + seasonId = item.id, + episodeId = null, + shuffled = shuffled, + ), + ) + } } else { PlaylistCreationResult.Error(null, "Episode has no seriesId") } } BaseItemKind.SERIES -> { - PlaylistCreationResult.Success( - createFromEpisode( - seriesId = item.id, - seasonId = null, - episodeId = null, - shuffled = shuffled, - ), - ) + if (shuffled) { + api.tvShowsApi + .getEpisodes( + seriesId = item.id, + limit = Playlist.MAX_SIZE, + sortBy = ItemSortBy.RANDOM, + fields = DetailItemFields, + ).content.items + .convertAndAddParts() + .let { + PlaylistCreationResult.Success(Playlist(it)) + } + } else { + val result by api.tvShowsApi.getNextUp(seriesId = item.id) + val nextUp = + result.items.firstOrNull() ?: api.tvShowsApi + .getEpisodes( + item.id, + limit = 1, + ).content.items + .firstOrNull() + if (nextUp != null) { + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = item.id, + seasonId = null, + episodeId = nextUp.id, + shuffled = shuffled, + ), + ) + } else { + PlaylistCreationResult.Error( + null, + "Could not determine next up episode for series: " + item.id, + ) + } + } } BaseItemKind.PLAYLIST -> { @@ -263,7 +306,7 @@ class PlaylistCreator }.let(::addAll) } } - PlaylistCreationResult.Success(Playlist(list, 0)) + PlaylistCreationResult.Success(Playlist(list)) } // Not support yet @@ -299,26 +342,49 @@ class PlaylistCreator * Get the playlists on the server for a given media type */ suspend fun getServerPlaylists( + query: String, mediaType: MediaType?, - scope: CoroutineScope, - ): List<PlaylistInfo?> { + ): List<PlaylistInfo> { + val userId = + serverRepository.currentUser?.id ?: throw IllegalStateException("No user found") val request = GetItemsRequest( + searchTerm = query.takeIf { it.isNotBlank() }, includeItemTypes = listOf(BaseItemKind.PLAYLIST), mediaTypes = mediaType?.let { listOf(mediaType) }, recursive = true, + limit = 25, + sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED), + sortOrder = listOf(SortOrder.DESCENDING), ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() - return TransformList(pager) { - it?.let { - PlaylistInfo( - id = it.id, - name = it.name ?: context.getString(R.string.unknown), - count = it.data.childCount ?: 0, - mediaType = it.data.mediaType, - ) + val playlists = GetItemsRequestHandler.execute(api, request).content.items + return playlists + .mapNotNull { playlist -> + try { + val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content + if (response.canEdit) { + PlaylistInfo( + id = playlist.id, + name = playlist.name ?: context.getString(R.string.unknown), + count = playlist.childCount ?: 0, + mediaType = playlist.mediaType, + ) + } else { + null + } + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + null + } else { + throw ex + } + } } - } +// .sortedWith( +// compareBy<PlaylistInfo> { +// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query) +// }.thenBy { it.name }, +// ) } suspend fun createServerPlaylist( 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 91df9518..174c103b 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 @@ -8,9 +8,9 @@ import android.os.Looper import android.view.Display import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -38,7 +38,7 @@ class RefreshRateService stream: MediaStream, switchRefreshRate: Boolean, switchResolution: Boolean, - ) = withContext(Dispatchers.IO) { + ) = withContext(WholphinDispatchers.IO) { if (!switchRefreshRate && !switchResolution) { Timber.v("Not switching either refresh rate nor resolution") return@withContext diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt new file mode 100644 index 00000000..a3b5b795 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt @@ -0,0 +1,34 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.RememberedTabDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.RememberedTab +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RememberedTabService + @Inject + constructor( + private val serverRepository: ServerRepository, + private val rememberedTabDao: RememberedTabDao, + ) { + suspend fun getRememberedTab(itemId: String): Int? = + withContext(WholphinDispatchers.IO) { + serverRepository.currentUser?.rowId?.let { userId -> + rememberedTabDao.getRememberedTab(userId, itemId)?.index + } + } + + suspend fun saveRememberedTab( + itemId: String, + tabIndex: Int, + ): Unit = + withContext(WholphinDispatchers.IO) { + serverRepository.currentUser?.rowId?.let { userId -> + rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex)) + } + } + } 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 index 6069c00a..c3fcb478 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import coil3.imageLoader import coil3.request.ImageRequest import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope @@ -10,10 +13,10 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -57,6 +60,7 @@ class ScreensaverService val keepScreenOn = MutableStateFlow(false) private var waitJob: Job? = null + private var dimJob: Job? = null init { userPreferencesService.flow @@ -65,7 +69,14 @@ class ScreensaverService val enabled = prefs.appPreferences.interfacePreferences.screensaverPreference.enabled keepScreenOnInternal(enabled) - ScreensaverState(enabled, false, false, false) + ScreensaverState( + enabled = enabled, + enabledTemp = false, + active = false, + paused = false, + dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled, + dimActive = false, + ) } }.launchIn(scope) } @@ -79,7 +90,10 @@ class ScreensaverService // Timber.v("pulse") _state.update { if (!it.active) { - it.copy(active = false) + it.copy( + active = false, + dimActive = false, + ) } else { it } @@ -94,7 +108,30 @@ class ScreensaverService .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds delay(startDelay) _state.update { - it.copy(active = true) + it.copy( + active = true, + dimActive = it.dimEnabled, + ) + } + } + } + } + dimJob?.cancel() + if (_state.value.dimEnabled) { + _state.update { + it.copy(dimActive = false) + } + if (!_state.value.paused) { + dimJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + Timber.v("state dim=%s", _state.value) + _state.update { + it.copy(dimActive = !_state.value.paused) } } } @@ -109,6 +146,7 @@ class ScreensaverService it.copy( enabledTemp = true, active = true, + dimActive = true, ) } } @@ -121,9 +159,13 @@ class ScreensaverService it.copy( enabledTemp = false, active = false, + dimActive = false, ) } - if (cancelJob) waitJob?.cancel() + if (cancelJob) { + waitJob?.cancel() + dimJob?.cancel() + } } /** @@ -131,17 +173,24 @@ class ScreensaverService */ fun keepScreenOn(keep: Boolean) { scope.launchDefault { - val screensaverEnabled = _state.value.enabled + val screensaverEnabled = state.value.enabled + val dimEnabled = state.value.dimEnabled Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) - if (screensaverEnabled) { + if (screensaverEnabled || dimEnabled) { // Page is requesting to keep screen on, so we don't wait to show the screensaver _state.update { - it.copy(active = false, paused = keep) + it.copy( + active = false, + dimActive = false, + paused = keep, + ) } if (!keep) { pulse() } - } else { + } + if (!screensaverEnabled) { + // If in-app screensaver is not enabled, send keep screen on to the OS keepScreenOnInternal(keep) } } @@ -224,7 +273,7 @@ class ScreensaverService if (index > pager.lastIndex) index = 0 } } - }.flowOn(Dispatchers.Default).cancellable() + }.flowOn(WholphinDispatchers.Default).cancellable() private suspend fun createPager(): ApiRequestPager<GetItemsRequest> { val prefs = @@ -245,6 +294,11 @@ class ScreensaverService ) return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() } + + companion object { + val enterAnimation = fadeIn(animationSpec = tween(durationMillis = 1000)) + val exitAnimation = fadeOut(animationSpec = tween(durationMillis = 500)) + } } data class ScreensaverState( @@ -252,6 +306,10 @@ data class ScreensaverState( val enabledTemp: Boolean, val active: Boolean, val paused: Boolean, + val dimEnabled: Boolean = false, + val dimActive: Boolean = false, ) { val show get() = (enabled || enabledTemp) && active && !paused + + val showDim get() = dimEnabled && dimActive && !paused } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index d9a340c9..d60988a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -6,13 +6,21 @@ import com.github.damontecres.wholphin.api.seerr.model.CreditCrew import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.RootFolder import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile +import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData +import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder +import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.toLocalDate import kotlinx.coroutines.flow.first @@ -20,6 +28,7 @@ import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -115,21 +124,7 @@ class SeerrService } BaseItemKind.PERSON -> { - api.personApi - .personPersonIdCombinedCreditsGet(personId = it) - .let { credits -> - val cast = - credits.cast - ?.take(25) - ?.map { createDiscoverItem(it) } - .orEmpty() - val crew = - credits.crew - ?.take(25) - ?.map { createDiscoverItem(it) } - .orEmpty() - cast + crew - } + personCredits(it) } else -> { @@ -141,6 +136,50 @@ class SeerrService null } + /** + * Get the combined movie & TV credits of a person as [DiscoverItem]s + * + * The two lists returned by the API are merged, de-duplicated by id since a person can be + * both cast and crew on the same title, and sorted by release date descending with undated + * entries last + */ + suspend fun personCredits(personId: Int): List<DiscoverItem> = + api.personApi + .personPersonIdCombinedCreditsGet(personId = personId) + .let { credits -> + val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty() + val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty() + (cast + crew) + .distinctBy { it.id } + .sortedByDescending { it.releaseDate } + } + + /** + * Create a [DiscoverItem] for a person in the library so that the full grid of their + * credits can be opened from their page + * + * @return the item or null if the person has no TMDB id + */ + fun discoverPerson(item: BaseItem): DiscoverItem? = + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + DiscoverItem( + id = it, + type = SeerrItemType.PERSON, + title = item.name, + subtitle = null, + overview = item.data.overview, + availability = SeerrAvailability.UNKNOWN, + releaseDate = null, + posterUrl = null, + backDropUrl = null, + logoUrl = null, + jellyfinItemId = null, + ) + } + suspend fun getTvSeries(item: BaseItem): TvDetails? = if (active.first()) { item.data.providerIds @@ -153,20 +192,15 @@ class SeerrService null } - private suspend fun createImageUrl( + suspend fun createImageUrl( imageType: ImageType, path: String?, mediaInfo: MediaInfo?, + backdropHeight: Int = 1080, + backdropWidth: Int = 1920, ): String? { if (mediaInfo != null) { - val itemId = - if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) { - mediaInfo.jellyfinMediaId.toUUIDOrNull() - } else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) { - mediaInfo.jellyfinMediaId4k.toUUIDOrNull() - } else { - null - } + val itemId = mediaInfo.jellyfinId if (itemId != null) { return imageUrlService.getItemImageUrl( itemId = itemId, @@ -174,21 +208,143 @@ class SeerrService ) } } - val current = seerrServerRepository.current.firstOrNull() ?: return null - val cacheImages = current.serverConfig.cacheImages == true - val base = - if (cacheImages) { - current.server.url.removeSuffix("/") + "/imageproxy/tmdb" + return path?.takeIf { it.isNotNullOrBlank() }?.let { + val current = seerrServerRepository.current.firstOrNull() ?: return null + val cacheImages = current.serverConfig.cacheImages == true + val base = + if (cacheImages) { + current.server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" + } + val prefix = + when (imageType) { + ImageType.PRIMARY -> "/t/p/w500" + ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" + } + } + + suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData { + var result = SeerrRequestData() + val current = seerrServerRepository.current.firstOrNull() + if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) { + if (type == SeerrItemType.MOVIE) { + val radarrs = api.serviceApi.serviceRadarrGet() + val radarr = + radarrs.firstOrNull { it.isDefault && !it.is4k } + ?: radarrs.firstOrNull { !it.is4k } + val radarr4k = + radarrs.firstOrNull { it.isDefault && it.is4k } + ?: radarrs.firstOrNull { it.is4k } + + if (radarr != null && radarr.id != null) { + val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id) + + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + serverId = radarr.id, + profiles = profiles, + rootFolders = rootFolders, + ) + } + } + if (radarr4k != null && radarr4k.id != null) { + val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id) + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + server4kId = radarr4k.id, + profiles4k = profiles, + rootFolders4k = rootFolders, + ) + } + } } else { - "https://image.tmdb.org" + val sonarrs = api.serviceApi.serviceSonarrGet() + val sonarr = + sonarrs.firstOrNull { it.isDefault && !it.is4k } + ?: sonarrs.firstOrNull { !it.is4k } + val sonarr4k = + sonarrs.firstOrNull { it.isDefault && it.is4k } + ?: sonarrs.firstOrNull { it.is4k } + + if (sonarr != null && sonarr.id != null) { + val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id) + + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + serverId = sonarr.id, + profiles = profiles, + rootFolders = rootFolders, + ) + } + } + if (sonarr4k != null && sonarr4k.id != null) { + val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id) + setProfilesAndFolder( + settings.profiles, + settings.rootFolders, + settings.server?.activeProfileId, + settings.server?.activeDirectory, + ) { profiles, rootFolders -> + result = + result.copy( + server4kId = sonarr4k.id, + profiles4k = profiles, + rootFolders4k = rootFolders, + ) + } + } } - val prefix = - when (imageType) { - ImageType.PRIMARY -> "/t/p/w500" - ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" - else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return result + } + + private fun setProfilesAndFolder( + profiles: List<ServiceProfile>?, + rootFolders: List<RootFolder>?, + activeProfileId: Int?, + activeDirectory: String?, + setter: (List<SeerrProfile>, List<SeerrRootFolder>) -> Unit, + ) { + val profiles = + profiles.orEmpty().mapNotNull { + if (it.id != null && it.name != null) { + SeerrProfile(it.id, it.name, activeProfileId == it.id) + } else { + null + } } - return "${base}${prefix}$path" + val rootFolders = + rootFolders.orEmpty().mapNotNull { + if (it.id != null && it.path != null) { + val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: "" + SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path) + } else { + null + } + } + setter.invoke(profiles, rootFolders) } suspend fun createDiscoverItem(item: Any): DiscoverItem = @@ -216,7 +372,8 @@ class SeerrService releaseDate = toLocalDate(movie.releaseDate), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = @@ -232,7 +389,8 @@ class SeerrService releaseDate = toLocalDate(movie.releaseDate), posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = @@ -248,7 +406,8 @@ class SeerrService releaseDate = toLocalDate(tv.firstAirDate), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = @@ -264,7 +423,8 @@ class SeerrService releaseDate = toLocalDate(tv.firstAirDate), posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = @@ -280,7 +440,8 @@ class SeerrService releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), - jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo), + jellyfinItemId = search.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = @@ -293,7 +454,7 @@ class SeerrService availability = SeerrAvailability.from(credit.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), posterUrl = createImageUrl( ImageType.PRIMARY, @@ -306,7 +467,8 @@ class SeerrService credit.backdropPath, credit.mediaInfo, ), - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, ) suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = @@ -319,7 +481,7 @@ class SeerrService availability = SeerrAvailability.from(credit.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), posterUrl = createImageUrl( ImageType.PRIMARY, @@ -332,6 +494,13 @@ class SeerrService credit.backdropPath, credit.mediaInfo, ), - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, ) } + +val MediaInfo.jellyfinId: UUID? + get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull() + +val MediaInfo.jellyfinIdAsString: String? + get() = jellyfinMediaId4k ?: jellyfinMediaId diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt index ec77c65c..708eb8b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.jellyfin.sdk.api.client.ApiClient @@ -94,6 +95,8 @@ class ServerEventListener .joinToString("\n") showToast(context, toast, Toast.LENGTH_LONG) } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") }.launchIn(activity.lifecycleScope) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt similarity index 60% rename from app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt rename to app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt index eeae86d9..97369c36 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt @@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.os.Build +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.getLogCatLines import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler +import com.google.protobuf.MessageLiteOrBuilder import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.json.Json @@ -22,10 +25,10 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Send media info to the server + * Send reports to the server such as media info or app logs */ @Singleton -class MediaReportService +class ServerReportService @Inject constructor( @param:ApplicationContext private val context: Context, @@ -45,45 +48,65 @@ class MediaReportService /** * Fetch the media info and send it to the server */ - fun sendReportFor(itemId: UUID) { + fun sendMediaReportFor(itemId: UUID) { ioScope.launchIO(ExceptionHandler(autoToast = true)) { val item = api.userLibraryApi.getItem(itemId = itemId).content - sendReportFor(item) + sendMediaReportFor(item) } } /** * Send the media report for the given item */ - suspend fun sendReportFor(item: BaseItemDto) { + suspend fun sendMediaReportFor(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 appPreferences = userPreferencesService.getCurrent().appPreferences val serverVersion = serverRepository.currentServer?.serverVersion val deviceProfile = - deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion) + deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion) val deviceProfileJson = json.encodeToString(deviceProfile) val body = - """ - Send media info + buildLogHeader("Send media info") + "\n\n" + + """ + playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()} + + experimental=${appPreferences.experimentalPreferences.toStringOneLine()} + + mediaSources=$sourcesJson + + deviceProfile=$deviceProfileJson + """.trimIndent() + body.chunked(2048).forEach { Timber.w(it) } + Timber.w("End send media info") + val response by api.clientLogApi.logFile(body) + showToast(context, "Sent! Filename=${response.fileName}") + } + + fun buildLogHeader(title: String): String { + val serverVersion = serverRepository.currentServer?.serverVersion + return """ + $title serverVersion=$serverVersion clientInfo=$clientInfo + flavor=${BuildConfig.FLAVOR} 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) + } + + suspend fun sendAppLogs() { + val logcat = getLogCatLines().joinToString("\n") { it.text } + val header = buildLogHeader("Send App Logs") + Timber.w(header) + val response by api.clientLogApi.logFile(header + "\n\n" + logcat) showToast(context, "Sent! Filename=${response.fileName}") } } + +private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ") 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 fa42469e..abe39959 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 @@ -27,9 +27,6 @@ class SetupNavigationManager fun navigateTo(destination: SetupDestination) { backStack[0] = destination log() - if (destination !is SetupDestination.AppContent) { - navigationManager.reloadHome() - } } private fun log() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 3a3a3e9c..09450b34 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -5,7 +5,9 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import org.jellyfin.sdk.model.api.BaseItemDto @@ -135,8 +137,8 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } // If the user has chosen a different language for the series, prefer that - val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference - + val audioLanguage = + seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) if (audioLanguage.isNotNullOrBlank()) { val sorted = candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) @@ -226,23 +228,17 @@ class StreamChoiceService ): MediaStream? { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { return null - } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { + } + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } + val subtitleLanguage = + seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { // Client-side manual override: User selected "Only Forced" in player menu - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } - val subtitleMode = when { playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { @@ -258,9 +254,17 @@ class StreamChoiceService else -> { // Fallback to the user's preference - userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT + } } - } + } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates .sortedWith( @@ -277,11 +281,13 @@ class StreamChoiceService SubtitlePlaybackMode.ALWAYS -> { if (subtitleLanguage.isNotNullOrBlank()) { candidates.firstOrNull { - it.language.equals(subtitleLanguage, true) || - it.language.isUnknown + // Prefer non-forced first + !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) + } ?: candidates.firstOrNull { + it.language.equalsLangOrUnknown(subtitleLanguage) } } else { - candidates.firstOrNull() + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } } @@ -371,3 +377,38 @@ private val String?.isUnknown: Boolean this.equals("undetermined", true) || this.equals("mul", true) || this.equals("zxx", true) + +private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown + +/** + * Based on the user's preferences, get their preferred language for audio or subtitles + * + * @param prefs the [UserPreferences] + */ +fun getPreferredLanguage( + type: MediaStreamType, + prefs: UserPreferences, + userConfig: UserConfiguration?, +): String? { + val (pref, profileLang) = + when (type) { + MediaStreamType.AUDIO -> { + prefs.userPreferences?.preferredAudioLanguage to + userConfig?.audioLanguagePreference + } + + MediaStreamType.SUBTITLE -> { + prefs.userPreferences?.preferredSubtitleLanguage to + userConfig?.subtitleLanguagePreference + } + + else -> { + throw IllegalArgumentException("Only audio or subtitle supported, not $type") + } + } + return when (pref) { + UserProfileSettings.USE_USER_PROFILE -> profileLang + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + }?.takeIf { it.isNotNullOrBlank() } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index 0921a1be..eedbffc5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -43,7 +43,7 @@ class SuggestionsCache private val memoryCache = InMemoryKache<String, CachedSuggestions>(maxSize = 8) { - creationScope = CoroutineScope(Dispatchers.IO) + creationScope = CoroutineScope(WholphinDispatchers.IO) } private fun cacheKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 800d31fd..aa64058d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -9,9 +9,9 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -81,7 +81,7 @@ class SuggestionsWorker .takeIf { it > 0 } ?: AppPreference.HomePageItems.defaultValue.toInt() - val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") + val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions") if (requestedParentId != null && requestedItemKind != null) { Timber.d( "Fetching on-demand suggestions for parent=%s kind=%s", @@ -128,7 +128,7 @@ class SuggestionsWorker supervisorScope { supportedViews .map { view -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { runCatching { Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) fetchAndCacheSuggestions( @@ -293,7 +293,7 @@ class SuggestionsWorker limit = freshLimit, ) } - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { val contextual = contextualDeferred.await() val random = randomDeferred.await() val fresh = freshDeferred.await() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt index e1b6cf1b..036ca505 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt @@ -14,10 +14,10 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow @@ -102,7 +102,7 @@ class ThemeSongPlayer Codec.Audio.FLAC, ), ) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.apply { stop() volume = volumeLevel @@ -127,7 +127,7 @@ class ThemeSongPlayer scope.launch { mutex.withLock { state.value.job?.cancelAndJoin() - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { Timber.v("Stopping theme song") player.stop() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 6be37807..22df106b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -34,7 +34,7 @@ class TrailerService // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: context.getString(R.string.trailer) + ?: context.resources.getQuantityString(R.plurals.trailers, 1) val subtitle = when (url.toUri().host) { "youtube.com", "www.youtube.com" -> "YouTube" 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 b56d8a7d..8c2d84cb 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.Manifest +import android.annotation.SuppressLint import android.content.ContentValues import android.content.Context import android.content.Intent @@ -21,8 +22,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.Version +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -61,8 +62,6 @@ class UpdateChecker private const val APK_MIME_TYPE = "application/vnd.android.package-archive" - private const val PERMISSION_REQUEST_CODE = 123456 - private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") val ACTIVE = BuildConfig.UPDATING_ENABLED @@ -131,7 +130,7 @@ class UpdateChecker suspend fun getRelease(version: Version): Release? { val url = "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" - return withContext(Dispatchers.IO) { + return withContext(WholphinDispatchers.IO) { val request = Request .Builder() @@ -146,7 +145,7 @@ class UpdateChecker * Get the latest released version */ suspend fun getLatestRelease(updateUrl: String): Release? = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val request = Request .Builder() @@ -158,8 +157,8 @@ class UpdateChecker private fun getRelease(request: Request): Release? { return okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { - val result = Json.parseToJsonElement(it.body!!.string()) + if (it.isSuccessful) { + val result = Json.parseToJsonElement(it.body.string()) val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull val version = Version.tryFromString(name) val publishedAt = @@ -195,11 +194,12 @@ class UpdateChecker /** * Download and install an update */ + @SuppressLint("RequestInstallPackagesPolicy") suspend fun installRelease( release: Release, callback: DownloadCallback, ) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { cleanup() val request = Request @@ -208,9 +208,9 @@ class UpdateChecker .get() .build() okHttpClient.newCall(request).execute().use { - if (it.isSuccessful && it.body != null) { + if (it.isSuccessful) { Timber.v("Request successful for ${release.downloadUrl}") - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { callback.contentLength(it.body.contentLength()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -230,7 +230,7 @@ class UpdateChecker contentValues, ) if (uri != null) { - it.body!!.byteStream().use { input -> + it.body.byteStream().use { input -> resolver.openOutputStream(uri).use { output -> copyTo(input, output!!, callback = callback) } @@ -290,7 +290,7 @@ class UpdateChecker downloadDir.mkdirs() val targetFile = File(downloadDir, APK_NAME) targetFile.outputStream().use { output -> - response.body!!.byteStream().use { input -> + response.body.byteStream().use { input -> copyTo(input, output, callback = callback) } } @@ -374,7 +374,7 @@ data class Release( Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), "#$1", ) - // Remove the last line for full changelog since its just a link + // Remove the last line for full changelog since it's just a link .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") } @@ -387,29 +387,30 @@ interface DownloadCallback { suspend fun copyTo( input: InputStream, out: OutputStream, - bufferSize: Int = 16 * 1024, + bufferSize: Int = 64 * 1024, callback: DownloadCallback, -): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(bufferSize) - var bytes = input.read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - withContext(Dispatchers.Main) { - callback.bytesDownloaded(bytesCopied) +): Long = + withContext(WholphinDispatchers.IO) { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = input.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + withContext(WholphinDispatchers.Main) { + callback.bytesDownloaded(bytesCopied) + } + bytes = input.read(buffer) } - bytes = input.read(buffer) + return@withContext bytesCopied } - return bytesCopied -} fun getDownloadUrl( assets: JsonArray, debug: Boolean, - supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(), + supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(), ): String? { - val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" } + val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" } val releaseSuffix = if (debug) "-debug" else "-release" val preferredNames = buildList { 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 0fe6be1e..03b8846f 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 @@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore 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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -19,13 +20,10 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, ) { - val flow = preferencesDataStore.data.map { UserPreferences(it) } - - suspend fun getCurrent(): UserPreferences = - serverRepository.currentUserDto!!.configuration.let { userConfig -> - val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - UserPreferences( - appPrefs, - ) + val flow: Flow<UserPreferences> = + preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user -> + UserPreferences(appPrefs, user?.appPreferences) } + + suspend fun getCurrent(): UserPreferences = flow.first() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index 2565f077..536dfb40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -12,9 +12,9 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext @@ -56,7 +56,7 @@ class UserSwitchListener user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList() Timber.i("Switching locale to %s", localeList) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { AppCompatDelegate.setApplicationLocales(localeList) } 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 d1ed9eac..06607cf9 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 @@ -1,18 +1,13 @@ 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 -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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,9 +15,7 @@ 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 -import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.android.androidDevice @@ -49,21 +42,21 @@ annotation class AuthOkHttpClient annotation class StandardOkHttpClient /** - * A [CoroutineScope] with [Dispatchers.IO] + * A [CoroutineScope] with [WholphinDispatchers.IO] */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope /** - * A [CoroutineScope] with [Dispatchers.Default] + * A [CoroutineScope] with [WholphinDispatchers.Default] */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope /** - * [Dispatchers.IO] + * [WholphinDispatchers.IO] * * @see IoCoroutineScope */ @@ -72,7 +65,7 @@ annotation class DefaultCoroutineScope annotation class IoDispatcher /** - * [Dispatchers.Default] + * [WholphinDispatchers.Default] * * @see DefaultCoroutineScope */ @@ -160,55 +153,10 @@ object AppModule { @Singleton fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore<AppPreferences>, - @IoCoroutineScope scope: CoroutineScope, - ) = object : RememberTabManager { - fun key(itemId: String): String = - serverRepository.current.value.let { - "${it?.server?.id}_${it?.user?.id}_$itemId" - } - - override fun getRememberedTab( - preferences: UserPreferences, - itemId: String, - defaultTab: Int, - ): Int { - if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { - return preferences.appPreferences.interfacePreferences - .getRememberedTabsOrDefault(key(itemId), defaultTab) - } else { - return defaultTab - } - } - - override fun saveRememberedTab( - preferences: UserPreferences, - itemId: String, - tabIndex: Int, - ) { - if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { - scope.launch(ExceptionHandler()) { - appPreference.updateData { - it.updateInterfacePreferences { - putRememberedTabs(key(itemId), tabIndex) - } - } - } - } - } - } - @Provides @Singleton @IoDispatcher - fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO @Provides @Singleton @@ -220,7 +168,7 @@ object AppModule { @Provides @Singleton @DefaultDispatcher - fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default @Provides @Singleton 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 62609628..29688664 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 @@ -16,6 +16,7 @@ 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.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences @@ -43,7 +44,7 @@ object DatabaseModule { ).addMigrations(Migrations.Migrate2to3) // .setQueryCallback({ sqlQuery, args -> // Timber.v("sqlQuery=$sqlQuery, args=$args") -// }, Dispatchers.IO.asExecutor()) +// }, WholphinDispatchers.IO.asExecutor()) .build() @Provides @@ -74,6 +75,10 @@ object DatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides @Singleton fun userPreferencesDataStore( 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 4a0e5dde..d3dbf4c7 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 @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.ui.SlimItemFields import dagger.assisted.Assisted @@ -226,27 +227,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } @@ -264,6 +265,7 @@ class TvProviderWorker .getLatestMedia( GetLatestMediaRequest( fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), imageTypeLimit = 1, parentId = null, groupItems = true, @@ -362,27 +364,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index e2c4f976..703182ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -9,6 +9,7 @@ import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache import coil3.network.CacheStrategy +import coil3.network.ConnectivityChecker import coil3.network.NetworkRequest import coil3.network.NetworkResponse import coil3.network.cachecontrol.CacheControlCacheStrategy @@ -95,6 +96,8 @@ fun CoilConfig( OkHttpNetworkFetcherFactory( cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, callFactory = { client }, + // Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android + connectivityChecker = { ConnectivityChecker { true } }, ), ) }.build() 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 2c633c35..312bc6cc 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 @@ -33,9 +33,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest @@ -74,6 +74,8 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { return if (index >= 0) index else null } +fun Iterable<CharSequence?>.joinNotBlank(sep: String): String? = filter { it.isNotNullOrBlank() }.letNotEmpty { it.joinToString(sep) } + /** * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly */ @@ -242,7 +244,7 @@ val BaseItemDto.timeRemaining: Duration? get() = userData?.playbackPositionTicks?.let { if (it > 0) { - runTimeTicks?.minus(it)?.ticks + runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO } } else { null } @@ -359,34 +361,34 @@ suspend fun showToast( context: Context, text: CharSequence, duration: Int, -) = withContext(Dispatchers.Main) { +) = withContext(WholphinDispatchers.Main) { Toast.makeText(context, text, duration).show() } suspend fun showToast( context: Context, text: CharSequence, -) = withContext(Dispatchers.Main) { +) = withContext(WholphinDispatchers.Main) { Toast.makeText(context, text, Toast.LENGTH_LONG).show() } /** - * Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] */ fun CoroutineScope.launchIO( context: CoroutineContext = ExceptionHandler(), start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -): Job = launch(context = Dispatchers.IO + context, start = start, block = block) +): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block) /** - * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [WholphinDispatchers.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) +): Job = launch(context = WholphinDispatchers.Default + context, start = start, block = block) /** * Converts a UUID to the format used server-side (ie without hyphens). @@ -410,7 +412,7 @@ fun logTab( ACRA.errorReporter.putCustomData("tabInfo", info) } -suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) +suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block) fun Modifier.dimAndBlur(enabled: Boolean) = this.ifElse( @@ -425,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } +fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) = + this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) } + /** * Check if this, coalescing nulls to zero, is greater than that */ @@ -446,3 +451,15 @@ fun <T> Flow<T>.collectLatestIn( * Easy way to combine two flows into a [Pair] */ fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) } + +/** + * Easy way to combine three flows into a [Triple] + */ +fun <T1, T2, T3> Flow<T1>.combineTriple( + flow: Flow<T2>, + flow2: Flow<T3>, +): Flow<Triple<T1, T2, T3>> = + combine(flow) { t1, t2 -> Pair(t1, t2) } + .combine(flow2) { (t1, t2), t3 -> + Triple(t1, t2, t3) + } 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 7b64b1a4..282c161e 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 @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.ui +import android.content.Context +import android.content.res.Resources +import android.text.format.DateFormat import androidx.annotation.StringRes import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString @@ -12,19 +15,36 @@ import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.time.format.FormatStyle +import java.util.Date import java.util.Locale +import kotlin.time.Duration -private var timeFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()) +/** + * Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current + * locale. [context] should be an Activity/composition context (e.g. `LocalContext.current`) so an + * in-app language switch (`AppCompatDelegate.setApplicationLocales`) is reflected; the application + * context may carry a stale locale. + */ +fun formatTime( + context: Context, + time: LocalDateTime, +): String = formatTime(context, time.toLocalTime()) -fun getTimeFormatter(): DateTimeFormatter { - if (timeFormatter.locale != Locale.getDefault()) { - timeFormatter = timeFormatter.withLocale(Locale.getDefault()) - } - return timeFormatter +fun formatTime( + context: Context, + time: LocalTime, +): String { + val instant = + time + .atDate(LocalDate.now()) + .atZone(ZoneId.systemDefault()) + .toInstant() + return DateFormat.getTimeFormat(context).format(Date.from(instant)) } private var dateFormatter: DateTimeFormatter = @@ -59,36 +79,74 @@ fun toLocalDate(date: String?): LocalDate? = } } +// Falls back to the default format if the application is not initialized (e.g. unit tests) +private fun formatStringWithFallback( + @StringRes resId: Int, + defaultFormat: String, + vararg args: Any, +): String { + val resources = + try { + WholphinApplication.instance.resources + } catch (_: UninitializedPropertyAccessException) { + null + } + return resources?.getString(resId, *args) + ?: String.format(Locale.getDefault(), defaultFormat, *args) +} + /** - * If the item has season & episode info, format as `S# E#` + * Format season & episode numbers using localized string resources. + * Returns null if [season] or [episode] is missing. + */ +fun formatSeasonEpisode( + season: Int?, + episode: Int?, + episodeEnd: Int? = null, + padded: Boolean = false, +): String? { + if (season == null || episode == null) return null + return if (episodeEnd != null) { + if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd) + } else { + formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd) + } + } else if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode) + } else { + formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode) + } +} + +/** Compact episode-only label, e.g. `E3`. */ +fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode) + +/** Fallback season title when the library has no season name, e.g. `Season 2`. */ +fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season) + +/** + * If the item has season & episode info, format as localized `S# E#` */ val BaseItemDto.seasonEpisode: String? get() = - if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { - "S$parentIndexNumber E$indexNumber-E$indexNumberEnd" - } else if (parentIndexNumber != null && indexNumber != null) { - "S$parentIndexNumber E$indexNumber" - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + ) /** - * If the item has season & episode info, format padded as `S## E##` + * If the item has season & episode info, format padded as localized `S##E##` */ val BaseItemDto.seasonEpisodePadded: String? get() = - if (parentIndexNumber != null && indexNumber != null) { - val season = parentIndexNumber?.toString()?.padStart(2, '0') - val episode = indexNumber?.toString()?.padStart(2, '0') - val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0') - if (endEpisode != null) { - "S${season}E$episode-E$endEpisode" - } else { - "S${season}E$episode" - } - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + padded = true, + ) val BaseItemDto.seriesProductionYears: String? get() = @@ -200,44 +258,64 @@ fun listToDotString( } } +@get:StringRes +val BaseItemKind.titleStringRes: Int + get() = formatTypeName(this) + @StringRes fun formatTypeName(type: BaseItemKind): Int = when (type) { - BaseItemKind.MOVIE -> R.string.movies - BaseItemKind.SERIES -> R.string.tv_shows + BaseItemKind.MOVIE -> R.string.movies_title + BaseItemKind.SERIES -> R.string.tv_shows_title BaseItemKind.EPISODE -> R.string.episodes BaseItemKind.VIDEO -> R.string.videos BaseItemKind.PLAYLIST -> R.string.playlists - BaseItemKind.PERSON -> R.string.people + BaseItemKind.PERSON -> R.string.people_title BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.AUDIO -> TODO() + BaseItemKind.AUDIO -> R.string.songs BaseItemKind.CHANNEL -> R.string.channels BaseItemKind.GENRE -> R.string.genres BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels - BaseItemKind.MUSIC_ALBUM -> TODO() - BaseItemKind.MUSIC_ARTIST -> TODO() - BaseItemKind.MUSIC_GENRE -> TODO() - BaseItemKind.MUSIC_VIDEO -> TODO() + BaseItemKind.MUSIC_ALBUM -> R.string.albums + BaseItemKind.MUSIC_ARTIST -> R.string.artists + BaseItemKind.MUSIC_GENRE -> R.string.genres + BaseItemKind.MUSIC_VIDEO -> R.string.music_videos BaseItemKind.PHOTO -> R.string.photos - BaseItemKind.PHOTO_ALBUM -> TODO() - BaseItemKind.PROGRAM -> TODO() - BaseItemKind.RECORDING -> TODO() + BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums + BaseItemKind.PROGRAM -> R.string.programs + BaseItemKind.RECORDING -> R.string.recordings BaseItemKind.SEASON -> R.string.tv_seasons BaseItemKind.STUDIO -> R.string.studios - BaseItemKind.TRAILER -> R.string.trailers + BaseItemKind.TRAILER -> R.string.trailers_title BaseItemKind.TV_CHANNEL -> R.string.channels - BaseItemKind.TV_PROGRAM -> TODO() - BaseItemKind.USER_ROOT_FOLDER -> TODO() - BaseItemKind.USER_VIEW -> TODO() - BaseItemKind.YEAR -> TODO() - BaseItemKind.AGGREGATE_FOLDER -> TODO() - BaseItemKind.AUDIO_BOOK -> TODO() - BaseItemKind.BASE_PLUGIN_FOLDER -> TODO() - BaseItemKind.BOOK -> TODO() - BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO() - BaseItemKind.COLLECTION_FOLDER -> TODO() - BaseItemKind.FOLDER -> TODO() - BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() - BaseItemKind.LIVE_TV_PROGRAM -> TODO() - BaseItemKind.PLAYLISTS_FOLDER -> TODO() + BaseItemKind.TV_PROGRAM -> R.string.programs + BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title + BaseItemKind.USER_VIEW -> R.string.user_views + BaseItemKind.YEAR -> R.string.year + BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title + BaseItemKind.AUDIO_BOOK -> R.string.audio_books + BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title + BaseItemKind.BOOK -> R.string.books + BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title + BaseItemKind.COLLECTION_FOLDER -> R.string.library + BaseItemKind.FOLDER -> R.string.folders_title + BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists + BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs + BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists + } + +/** + * Format a [Duration] into a localized "Xh Ym Zs" string using string resources. + */ +fun Resources.formatDuration(duration: Duration): String = + duration.toComponents { hours, minutes, seconds, _ -> + when { + hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds) + hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes) + hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds) + hours > 0 -> getString(R.string.duration_hours, hours.toInt()) + minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds) + minutes > 0 -> getString(R.string.duration_minutes, minutes) + else -> getString(R.string.duration_seconds, seconds) + } } 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 ab0f69f8..2e57b407 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 @@ -38,27 +38,25 @@ object AppColors { val Purple = Color(147, 51, 234) val Green = Color(74, 222, 128) val Yellow = Color(234, 179, 8) + val Red = Color(204, 38, 38) } } const val DEFAULT_PAGE_SIZE = 100 /** - * The default [ItemFields] to fetch for most queries + * The [ItemFields] to fetch for detailed paged and playback */ -val DefaultItemFields = +val DetailItemFields = listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.SEASON_USER_DATA, - ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.TRICKPLAY, ItemFields.SORT_NAME, ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.PARENT_ID, ItemFields.CAN_DELETE, + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ) /** @@ -66,27 +64,37 @@ val DefaultItemFields = */ val SlimItemFields = listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.SEASON_USER_DATA, - ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, + ) + +/** + * ItemFields for displaying items in rows such as in a [com.github.damontecres.wholphin.ui.cards.ItemRow] with [com.github.damontecres.wholphin.ui.cards.SeasonCard] + */ +val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO + +val HomeItemFields = + listOf( + ItemFields.OVERVIEW, ItemFields.CAN_DELETE, ) val PhotoItemFields = - DefaultItemFields + + DetailItemFields + listOf( ItemFields.WIDTH, ItemFields.HEIGHT, ) +val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO) + object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp const val HEIGHT_EPISODE = 128 + const val HEIGHT_LIVE_TV = 96 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 8d00edfd..4536a364 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,6 +1,5 @@ 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 @@ -74,7 +73,7 @@ fun BannerCard( val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current val fillHeight = - remember(cardHeight) { + remember(cardHeight, density) { if (cardHeight.isSpecified) { with(density) { cardHeight.roundToPx() @@ -219,13 +218,11 @@ fun BannerCardWithTitle( 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) 0.dp else 8.dp) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = cardHeight * aspectRationToUse Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.width(width), ) { BannerCard( @@ -245,13 +242,7 @@ fun BannerCardWithTitle( imageContentScale = imageContentScale, useSeriesForPrimary = useSeriesForPrimary, ) - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = title ?: "", style = MaterialTheme.typography.bodyLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index 54d1d2fc..34966e43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt @@ -5,39 +5,62 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.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.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.tv.material3.Card +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.roundSeconds -import kotlin.time.Duration +import org.jellyfin.sdk.model.api.ImageType /** * Card for a [com.github.damontecres.wholphin.data.model.Chapter] */ @Composable fun ChapterCard( - name: String?, - position: Duration, - imageUrl: String?, + chapter: Chapter, onClick: () -> Unit, - aspectRatio: Float, modifier: Modifier = Modifier, cardHeight: Dp = 120.dp, onLongClick: (() -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(chapter, cardHeight, density) { + imageUrlService.getItemImageUrl( + itemId = chapter.itemId, + imageType = ImageType.CHAPTER, + tag = chapter.tag, + imageIndex = chapter.index, + fillHeight = with(density) { cardHeight.roundToPx() }, + ) + } + var width by remember { mutableStateOf(AspectRatios.WIDE * cardHeight) } Card( - modifier = modifier.size(cardHeight * aspectRatio, cardHeight), + modifier = modifier.height(cardHeight), onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -48,22 +71,36 @@ fun ChapterCard( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize(), + onSuccess = { + width = + with(density) { + it.painter.intrinsicSize.width + .toDp() + } + }, ) Box( modifier = Modifier + .width(width) .align(Alignment.BottomStart) - .fillMaxWidth() .background(AppColors.TransparentBlack50), ) { Column(modifier = Modifier.padding(4.dp)) { - name?.let { + chapter.name?.let { Text( text = it, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, ) } + val resources = LocalResources.current + val positionText = + remember(chapter.position) { resources.formatDuration(chapter.position.roundSeconds) } Text( - text = position.roundSeconds.toString(), + text = positionText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, ) } } 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 b1e64a2a..5fa6316f 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 @@ -21,7 +21,6 @@ import com.github.damontecres.wholphin.data.model.Chapter @Composable fun ChapterRow( chapters: List<Chapter>, - aspectRatio: Float, onClick: (Chapter) -> Unit, modifier: Modifier = Modifier, onLongClick: ((Chapter) -> Unit)? = null, @@ -47,11 +46,8 @@ fun ChapterRow( ) { itemsIndexed(chapters) { index, item -> ChapterCard( - name = item.name, - position = item.position, - imageUrl = item.imageUrl, + chapter = item, onClick = { onClick(item) }, - aspectRatio = aspectRatio, modifier = Modifier, onLongClick = onLongClick?.let { { it(item) } }, ) 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 815c6f35..20cab060 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 @@ -59,8 +59,6 @@ fun DiscoverItemCard( width: Dp = Cards.height2x3 * AspectRatios.TALL, ) { 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 @@ -78,7 +76,7 @@ fun DiscoverItemCard( } Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.size(width, Dp.Unspecified), ) { Card( @@ -130,7 +128,16 @@ fun DiscoverItemCard( AvailableIndicator(Modifier.align(Alignment.TopEnd)) } - else -> {} + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed +// BlocklistedIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + null, + -> { + } } if (showOverlay) { val color = @@ -171,13 +178,7 @@ fun DiscoverItemCard( } } } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, @@ -284,6 +285,31 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { } } +@Composable +fun BlocklistedIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Red.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_xmark), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + @PreviewTvSpec @Composable private fun Preview() { @@ -292,6 +318,7 @@ private fun Preview() { PendingIndicator() AvailableIndicator() PartiallyAvailableIndicator() + BlocklistedIndicator() } } } 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 deleted file mode 100644 index 78080c3a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ /dev/null @@ -1,162 +0,0 @@ -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.aspectRatio -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.foundation.shape.RoundedCornerShape -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.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.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.seasonEpisode -import kotlinx.coroutines.delay - -@Composable -fun EpisodeCard( - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - imageHeight: Dp = Dp.Unspecified, - imageWidth: Dp = Dp.Unspecified, - showImageOverlay: Boolean = false, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val dto = item?.data - 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 aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN - val width = imageHeight * aspectRatio - val height = imageWidth * (1f / aspectRatio) - val density = LocalDensity.current - val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } - Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), - modifier = modifier.size(width, height), - ) { - Card( - modifier = - Modifier - .size(imageWidth, imageHeight) - .aspectRatio(aspectRatio), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - ItemCardImage( - item = item, - name = item?.name, - showOverlay = showImageOverlay, - favorite = dto?.userData?.isFavorite ?: false, - watched = dto?.userData?.played ?: false, - unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, - watchedPercent = dto?.userData?.playedPercentage, - numberOfVersions = dto?.mediaSourceCount ?: 0, - useFallbackText = false, - fillWidth = imageWidthPx, - modifier = - Modifier - .fillMaxSize(), - ) - Box( - modifier = - Modifier - .align(Alignment.TopEnd) - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(8.dp), - ), - ) { - Text( - text = dto?.seasonEpisode ?: "", - modifier = Modifier.padding(4.dp), - ) - } - } - } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { - Text( - text = dto?.seriesName ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.name ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 1d9927fe..238adaf8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -17,7 +17,7 @@ fun ExtrasRow( modifier: Modifier = Modifier, ) { ItemRow( - title = stringResource(R.string.extras), + title = stringResource(R.string.extras_title), items = extras, onClickItem = onClickItem, onLongClickItem = onLongClickItem, @@ -38,7 +38,7 @@ fun ExtrasRow( unplayedItemCount = -1, playedPercentage = item?.playedPercentage ?: 0.0, numberOfVersions = -1, - aspectRatio = AspectRatios.FOUR_THREE, // TODO + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, ) }, modifier = modifier, 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 a32fece9..654999f4 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 @@ -30,7 +30,6 @@ import coil3.request.crossfade import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Genre -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme import java.util.UUID 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 5cf16a61..f49f2d0b 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 @@ -52,8 +52,6 @@ fun GridCard( ) { val dto = item?.data 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 @@ -71,7 +69,7 @@ fun GridCard( } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Card( @@ -107,13 +105,7 @@ fun GridCard( ) } AnimatedVisibility(showTitle) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 63f6bdd8..aaa3de97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.logCoilError import org.jellyfin.sdk.model.api.ImageType @@ -67,14 +68,16 @@ fun ItemCardImage( ) { val imageUrlService = LocalImageUrlService.current val imageUrl = - remember(item) { - item?.let { + remember(item, imageType, fillWidth, fillHeight) { + if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) { imageUrlService.getItemImageUrl( item, imageType, fillWidth = fillWidth, fillHeight = fillHeight, ) + } else { + null } } ItemCardImage( @@ -262,7 +265,7 @@ fun ItemCardImageOverlay( } } - watchedPercent?.let { percent -> + if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) { Box( modifier = Modifier @@ -271,7 +274,7 @@ fun ItemCardImageOverlay( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) .height(Cards.playedPercentHeight) - .fillMaxWidth((percent / 100.0).toFloat()), + .fillMaxWidth((watchedPercent / 100.0).toFloat()), ) } } 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 4e146134..3154d257 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 @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -26,11 +24,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Border @@ -99,10 +95,6 @@ fun PersonCard( focusedAfterDelay = false } - // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates - val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") - val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") - Column( verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier, @@ -170,21 +162,7 @@ fun PersonCard( .clip(CircleShape), ) } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - // Optimization: move animation reads to layout/draw phase - .offset { - IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx()) - }.layout { measurable, constraints -> - val paddingPx = spaceBelowState.value.roundToPx() - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + paddingPx) { - placeable.placeRelative(0, 0) - } - }.fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = name ?: "", maxLines = 1, 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 140cc2fd..637aa5cf 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 @@ -33,7 +33,7 @@ fun PersonRow( people: List<Person>, onClick: (Person) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people, + @StringRes title: Int = R.string.people_title, onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } @@ -84,7 +84,7 @@ fun DiscoverPersonRow( people: List<DiscoverItem>, onClick: (DiscoverItem) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people, + @StringRes title: Int = R.string.people_title, onLongClick: ((Int, DiscoverItem) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } 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 962ab727..1992cab4 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -24,8 +23,8 @@ 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.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.MaterialTheme @@ -38,51 +37,21 @@ import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.ImageType /** - * A Card for a TV Show Season, but can generically show most items + * A Card for a TV Show Season, but can generally show most items */ @Composable fun SeasonCard( item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, + imageHeight: Dp, modifier: Modifier = Modifier, - imageHeight: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, showImageOverlay: Boolean = false, aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL, ) { - val imageUrlService = LocalImageUrlService.current - val density = LocalDensity.current - val imageUrl = - remember(item, imageHeight, imageWidth, density) { - if (item != null) { - val fillHeight = - if (imageHeight != Dp.Unspecified) { - with(density) { - imageHeight.roundToPx() - } - } else { - null - } - val fillWidth = - if (imageWidth != Dp.Unspecified) { - with(density) { - imageWidth.roundToPx() - } - } else { - null - } - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = fillWidth, - fillHeight = fillHeight, - ) - } else { - null - } - } + val imageUrl = rememberImageUrl(item, imageHeight, imageWidth) SeasonCard( title = item?.title, subtitle = item?.subtitle, @@ -104,6 +73,47 @@ fun SeasonCard( ) } +@Composable +fun rememberImageUrl( + item: BaseItem?, + imageHeight: Dp, + imageWidth: Dp = Dp.Unspecified, +): String? { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + return remember(item, imageHeight, imageWidth, density) { + if (item != null) { + val fillHeight = + if (imageHeight.isSpecified) { + with(density) { + imageHeight.roundToPx() + } + } else { + null + } + val fillWidth = + if (imageWidth.isSpecified) { + with(density) { + imageWidth.roundToPx() + } + } else { + null + } + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } +} + +private val spaceBetweenFocused = 4.dp +private val spaceBetweenUnfocused = 12.dp + /** * A Card for a TV Show Season, but can generically show most items */ @@ -129,8 +139,11 @@ fun SeasonCard( ) { val focused by interactionSource.collectIsFocusedAsState() // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates - val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween") - val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow") + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) @@ -179,14 +192,12 @@ fun SeasonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - // Optimization: move animation reads to layout/draw phase - .offset { - IntOffset(0, (spaceBetween.value - 4.dp).roundToPx()) - }.layout { measurable, constraints -> - val paddingPx = spaceBelow.value.roundToPx() + .layout { measurable, constraints -> + val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() + val bottomPaddingPx = spaceBetween.value.roundToPx() val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + paddingPx) { - placeable.placeRelative(0, 0) + layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { + placeable.placeRelative(0, topPaddingPx) } }.fillMaxWidth(), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt new file mode 100644 index 00000000..90d99281 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt @@ -0,0 +1,46 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Slides the [content] up/down as the card is [focused] or not + * + * This is used for the card's title, moving it down when focused as the card itself scales up + */ +@Composable +fun SlidingCardText( + focused: Boolean, + modifier: Modifier = Modifier, + spaceBetweenFocused: Dp = 4.dp, + spaceBetweenUnfocused: Dp = 12.dp, + content: @Composable ColumnScope.() -> Unit, +) { + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + modifier + .layout { measurable, constraints -> + val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx() + val bottomPaddingPx = spaceBetween.value.roundToPx() + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) { + placeable.placeRelative(0, topPaddingPx) + } + }.fillMaxWidth(), + content = content, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt index fb89a58b..c128285d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt @@ -32,9 +32,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.Studio -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme import java.util.UUID diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt index 56748473..fa7df8d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt @@ -53,8 +53,6 @@ fun ViewMoreCard( showTitle: Boolean = 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) var focusedAfterDelay by remember { mutableStateOf(false) } val hideOverlayDelay = 500L @@ -79,7 +77,7 @@ fun ViewMoreCard( size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio)) } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Card( @@ -95,24 +93,20 @@ fun ViewMoreCard( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), ), ) { - Box { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { Icon( imageVector = Icons.Default.ArrowForward, tint = MaterialTheme.colorScheme.onSurface, contentDescription = "View more", - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(.66f), ) } } if (showTitle) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .width(width) - .padding(bottom = spaceBelow), - ) { + SlidingCardText(focused) { Text( text = stringResource(R.string.view_more), maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt index 0a92199b..ce8952a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt @@ -45,6 +45,8 @@ fun WatchedIconBackground(): Color = AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border.copy(alpha = 1f) AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer @@ -60,6 +62,8 @@ fun WatchedIconColor(): Color = AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, + AppThemeColors.RED, + AppThemeColors.BROWN, -> Color.White // MaterialTheme.colorScheme.onSurface } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt index 4e3b13ab..e00a2185 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt @@ -7,7 +7,10 @@ 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.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 @@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable fun CircularProgress(modifier: Modifier = Modifier) { @@ -58,3 +64,41 @@ fun LoadingPage( ) } } + +/** + * Fill the space and take focus, showing a loading indicator after the specified delay + */ +@Composable +fun DelayedLoadingPage( + modifier: Modifier = Modifier, + focusEnabled: Boolean = true, + delay: Duration = 300.milliseconds, +) { + val focusRequester = remember { FocusRequester() } + if (focusEnabled) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + var show by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(delay) + show = true + } + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable(focusEnabled), + ) { + if (show) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier + .align(Alignment.Center) + .size(48.dp), + ) + } + } +} 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 773bdbee..fc6d132a 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 @@ -9,6 +9,7 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,6 +22,7 @@ import androidx.compose.runtime.remember 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.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -40,6 +42,7 @@ import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec @@ -112,7 +115,7 @@ fun CollectionFolderGrid( columns = viewOptions.columns, spacing = viewOptions.spacing.dp, bringIntoViewSpec = - remember(viewOptions) { + remember(viewOptions, density) { val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } if (viewOptions.showDetails) { ScrollToTopBringIntoViewSpec(spacingPx) @@ -204,7 +207,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind> } CollectionType.TVSHOWS -> { - listOf(BaseItemKind.SERIES) + listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } CollectionType.HOMEVIDEOS -> { @@ -219,6 +222,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind> ) } + CollectionType.MUSICVIDEOS -> { + listOf(BaseItemKind.MUSIC_VIDEO) + } + CollectionType.BOXSETS -> { listOf(BaseItemKind.BOX_SET) } @@ -227,7 +234,33 @@ val CollectionType.baseItemKinds: List<BaseItemKind> listOf(BaseItemKind.PLAYLIST) } - else -> { + CollectionType.PHOTOS -> { + listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) + } + + CollectionType.LIVETV -> { + listOf( + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + ) + } + + CollectionType.TRAILERS -> { + listOf(BaseItemKind.TRAILER) + } + + CollectionType.BOOKS -> { + listOf(BaseItemKind.BOOK) + } + + CollectionType.FOLDERS -> { + listOf(BaseItemKind.FOLDER) + } + + CollectionType.UNKNOWN -> { listOf() } } @@ -256,6 +289,7 @@ data class GridClickActions( @Composable fun CollectionFolderHeader( + listIsNotEmpty: Boolean, showHeader: Boolean, showTitle: Boolean, playEnabled: Boolean, @@ -266,10 +300,14 @@ fun CollectionFolderHeader( getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, onClickShowViewOptions: () -> Unit, onClickPlayAll: (Boolean) -> Unit, + onClickRandom: () -> Unit, modifier: Modifier = Modifier, currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List<ItemFilterBy<*>> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, + onShowFilterDropdown: ((Boolean) -> Unit)? = null, + filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, + randomButtonFocusRequester: FocusRequester = remember { FocusRequester() }, ) { AnimatedVisibility( showHeader, @@ -277,6 +315,7 @@ fun CollectionFolderHeader( exit = shrinkVertically(), modifier = modifier, ) { + val focusRequester = remember { FocusRequester() } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), @@ -286,61 +325,84 @@ fun CollectionFolderHeader( } val endPadding = 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + Row( - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(start = 16.dp, end = endPadding) .fillMaxWidth(), ) { - if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) - } - if (filterOptions.isNotEmpty()) { - FilterByButton( - filterOptions = filterOptions, - current = currentFilter, - onFilterChange = onFilterChange, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier, - ) - } - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = onClickShowViewOptions, - modifier = Modifier, + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier.focusRequester(focusRequester), ) } + if (filterOptions.isNotEmpty()) { + FilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = + Modifier + .focusRequester(filterButtonFocusRequester) + .ifElse( + sortOptions.isEmpty(), + Modifier.focusRequester(focusRequester), + ), + onShow = onShowFilterDropdown, + ) + } + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickShowViewOptions, + modifier = + Modifier.ifElse( + sortOptions.isEmpty() && filterOptions.isEmpty(), + Modifier.focusRequester(focusRequester), + ), + ) } - if (playEnabled) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { + + Spacer(Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandableFaButton( + title = R.string.sort_by_random, + iconStringRes = R.string.fa_dice, + onClick = onClickRandom, + modifier = Modifier.focusRequester(randomButtonFocusRequester), + enabled = listIsNotEmpty, + ) + + if (playEnabled) { ExpandablePlayButton( title = R.string.play, resume = Duration.ZERO, icon = Icons.Default.PlayArrow, onClick = { onClickPlayAll.invoke(false) }, + enabled = listIsNotEmpty, ) ExpandableFaButton( title = R.string.shuffle, iconStringRes = R.string.fa_shuffle, onClick = { onClickPlayAll.invoke(true) }, + enabled = listIsNotEmpty, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index 3c69e5ce..a048ed94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -28,6 +28,7 @@ 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 @@ -38,6 +39,7 @@ 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.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItemDefaults @@ -58,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.AlphabetButtons import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.rememberInt @@ -97,13 +100,15 @@ fun CollectionFolderList( val positionFocusRequester = remember { FocusRequester() } val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } fun jumpTo(newPosition: Int) { scope.launch { - position = newPosition - listState.animateScrollToItem(newPosition) - positionFocusRequester.tryRequestFocus() + if (newPosition >= 0) { + position = newPosition + listState.animateScrollToItem(newPosition) + positionFocusRequester.tryRequestFocus() + } } } @@ -357,7 +362,7 @@ fun ListItemGeneric( ?.runTimeTicks ?.ticks ?.roundMinutes - ?.toString() ?: "", + ?.let { LocalResources.current.formatDuration(it) } ?: "", ) }, scale = scale, @@ -419,7 +424,7 @@ fun ListItemMovie( ?.runTimeTicks ?.ticks ?.roundMinutes - ?.toString() ?: "", + ?.let { LocalResources.current.formatDuration(it) } ?: "", ) if (item?.played == true) { WatchedIcon(padding = 0.dp) @@ -546,7 +551,7 @@ fun ListItemAlbum( .runTimeTicks ?.ticks ?.roundMinutes - ?.toString() ?: "", + ?.let { LocalResources.current.formatDuration(it) } ?: "", ) } } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index ffb9cf92..52918e57 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -44,20 +44,17 @@ 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.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService 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.SlimItemFields -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault @@ -67,20 +64,22 @@ import com.github.damontecres.wholphin.ui.rememberInt 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.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue 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.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -95,8 +94,8 @@ 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.MediaType import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUID @@ -121,14 +120,17 @@ class CollectionFolderViewModel private val mediaManagementService: MediaManagementService, private val musicService: MusicService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, @Assisted val itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @Assisted("recursive") private val recursive: Boolean, @Assisted private val collectionFilter: CollectionFolderFilter, @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, @Assisted defaultViewOptions: ViewOptions, - ) : ViewModel() { + ) : ViewModel(), + ContextMenuProvider, + CollectionFolderViewActions { @AssistedFactory interface Factory { fun create( @@ -163,7 +165,8 @@ class CollectionFolderViewModel val libraryDisplayInfo = serverRepository.currentUser?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) + val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId + libraryDisplayInfoDao.getItem(user, id) } _state.update { it.copy( @@ -240,7 +243,7 @@ class CollectionFolderViewModel val libraryDisplayInfo = LibraryDisplayInfo( userId = user.rowId, - itemId = itemId, + itemId = collectionFilter.libraryDisplayInfoIdOverride ?: itemId, sort = newSort.sort, direction = newSort.direction, filter = newFilter, @@ -252,9 +255,10 @@ class CollectionFolderViewModel } } - fun saveViewOptions(viewOptions: ViewOptions) { + override fun saveViewOptions(viewOptions: ViewOptions) { + position = 0 _state.update { it.copy(viewOptions = viewOptions) } - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) if (!viewOptions.showBackdrop) { backdropService.clearBackdrop() @@ -262,7 +266,25 @@ class CollectionFolderViewModel } } - fun onFilterChange( + override fun onClickRandom() { + viewModelScope.launchIO { + try { + val random = + (state.value.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking() + Timber.d("Got random item: %s", random?.id) + random?.destination()?.let { + navigateTo(random.destination()) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting random") + showToast(context, "Error: ${ex.localizedMessage}}") + } + } + } + + override fun onFilterChange( newFilter: GetItemsFilter, recursive: Boolean, ) { @@ -277,7 +299,7 @@ class CollectionFolderViewModel ) } - fun onSortChange( + override fun onSortChange( sortAndDirection: SortAndDirection, recursive: Boolean, filter: GetItemsFilter, @@ -298,7 +320,7 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) = viewModelScope.launch(Dispatchers.IO) { + ) = viewModelScope.launch(WholphinDispatchers.IO) { _state.update { it.copy( items = DataLoadingState.Loading, @@ -375,8 +397,33 @@ class CollectionFolderViewModel ) newPager } + + GetItemsFilterOverride.ARTIST -> { + ApiRequestPager( + api, + createGetArtistsRequest(filter), + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } } + /** + * Shared with [positionOfLetter] so that the alphabet jump counts the same artists that are + * actually paged through + */ + private fun createGetArtistsRequest(filter: GetItemsFilter): GetArtistsRequest { + val item = state.value.item.successValue + return filter.applyTo( + GetArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + fields = SlimItemFields, + ), + ) + } + private fun createGetItemsRequest( sortAndDirection: SortAndDirection, recursive: Boolean, @@ -433,63 +480,95 @@ class CollectionFolderViewModel return request } - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + filterOptionCache.getFilterOptionValues( itemId.toUUID(), filterOption, ) - suspend fun positionOfLetter(letter: Char): Int? = - withContext(Dispatchers.IO) { - val sort = state.value.sortAndDirection + /** + * The count must come from the same endpoint that [createPager] pages through, otherwise + * the index refers to a different result set and lands past the end of the list. + */ + override suspend fun positionOfLetter(letter: Char): Int? = + withContext(WholphinDispatchers.IO) { val filter = state.value.filter - val request = - createGetItemsRequest( - sortAndDirection = sort, - recursive = recursive, - filter = filter, - ).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount + when (filter.override) { + GetItemsFilterOverride.ARTIST -> { + GetArtistsHandler.countMatching( + api = api, + request = + createGetArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + + // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be + // counted up to a letter at all + GetItemsFilterOverride.PERSON -> { + null + } + + GetItemsFilterOverride.NONE -> { + GetItemsRequestHandler.countMatching( + api = api, + request = + createGetItemsRequest( + sortAndDirection = state.value.sortAndDirection, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + } } - fun setWatched( + override fun setWatched( position: Int, itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (state.value.items as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } - fun setFavorite( + override fun setFavorite( position: Int, itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - (state.value.items as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } - fun updateBackdrop(item: BaseItem) { + override fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) } } - fun navigateTo(destination: Destination) { + override fun navigateTo(destination: Destination) { release() navigationManager.navigateTo(destination) } @@ -509,7 +588,7 @@ class CollectionFolderViewModel } } - fun deleteItem( + override fun deleteItem( index: Int, item: BaseItem, ) { @@ -520,7 +599,7 @@ class CollectionFolderViewModel } } - fun canDelete( + override fun canDelete( item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) @@ -529,6 +608,10 @@ class CollectionFolderViewModel item: BaseItem, index: Int, ) = addToQueue(api, musicService, item, index) + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true } data class CollectionFolderState( @@ -637,7 +720,6 @@ fun CollectionFolderView( useSeriesForPrimary: Boolean = true, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, focusRequesterOnEmpty: FocusRequester? = null, - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( key = viewModelKey, @@ -653,42 +735,96 @@ fun CollectionFolderView( }, ) { val state by viewModel.state.collectAsState() - var position by rememberInt(viewModel.position) + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() - var showViewOptions by rememberSaveable { mutableStateOf(false) } - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(viewModel.position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(viewModel.position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onDeleteItem = { viewModel.deleteItem(viewModel.position, it) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) + onPauseOrDispose { + viewModel.release() } + } + CollectionFolderViewContent( + preferences = preferences, + state = state, + savedPosition = viewModel.position, + itemId = itemId, + initialFilter = initialFilter, + recursive = recursive, + actions = actions, + sortOptions = sortOptions, + playEnabled = playEnabled, + defaultViewOptions = defaultViewOptions, + viewActions = viewModel, + modifier = modifier, + provider = viewModel, + showTitle = showTitle, + positionCallback = { columns, index -> + viewModel.position = index + positionCallback?.invoke(columns, index) + }, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, + ) +} + +interface CollectionFolderViewActions { + fun navigateTo(destination: Destination) + + fun updateBackdrop(item: BaseItem) + + fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) + + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) + + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> + + suspend fun positionOfLetter(letter: Char): Int? + + fun saveViewOptions(viewOptions: ViewOptions) + + /** + * Navigate to a random item + */ + fun onClickRandom() +} + +@Composable +fun CollectionFolderViewContent( + preferences: UserPreferences, + state: CollectionFolderState, + savedPosition: Int, + itemId: String, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + viewActions: CollectionFolderViewActions, + modifier: Modifier = Modifier, + provider: ContextMenuProvider, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) { + var position by rememberInt(savedPosition) + + // Track if leaving page due to clicking the random button so focus can be restored there + var clickedRandom by rememberSaveable { mutableStateOf(false) } + + val contextMenu = rememberContextMenu(preferences, provider) + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var filterDropdownShowing by remember { mutableStateOf(false) } + val headerRowFocusRequester = remember { FocusRequester() } + val filterButtonFocusRequester = remember { FocusRequester() } + val randomButtonFocusRequester = remember { FocusRequester() } val gridActions = remember(actions) { @@ -702,18 +838,7 @@ fun CollectionFolderView( if (actions.onLongClickItem != null) { actions.onLongClickItem.invoke(index, item) } else { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) + contextMenu.showContextMenu(index, item) } }, onClickPlayAll = @@ -739,7 +864,7 @@ fun CollectionFolderView( filter = state.filter, ) } - viewModel.navigateTo(destination) + viewActions.navigateTo(destination) } Unit }, @@ -758,7 +883,7 @@ fun CollectionFolderView( } else { Destination.Playback(item) } - viewModel.navigateTo(destination) + viewActions.navigateTo(destination) }, ) } @@ -782,14 +907,6 @@ fun CollectionFolderView( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Column(modifier = Modifier.fillMaxSize()) { - val headerRowFocusRequester = remember { FocusRequester() } - LifecycleResumeEffect(itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } - } var showHeader by rememberSaveable { mutableStateOf(true) } val gridFocusRequester = remember { FocusRequester() } val pager = remember(state.items) { state.items.successValue } @@ -797,30 +914,38 @@ fun CollectionFolderView( val focusedItem = pager?.getOrNull(position) if (state.viewOptions.showBackdrop) { LaunchedEffect(focusedItem) { - focusedItem?.let(viewModel::updateBackdrop) + focusedItem?.let(viewActions::updateBackdrop) } } CollectionFolderHeader( + listIsNotEmpty = state.items.successValue?.isNotEmpty() == true, showHeader = showHeader || state.items !is DataLoadingState.Success, showTitle = showTitle, - playEnabled = playEnabled && state.items.successValue?.isNotEmpty() == true, + playEnabled = playEnabled, title = title, sortAndDirection = state.sortAndDirection, onSortChange = { - viewModel.onSortChange(it, recursive, state.filter) + viewActions.onSortChange(it, recursive, state.filter) }, sortOptions = sortOptions, currentFilter = state.filter, onFilterChange = { - viewModel.onFilterChange(it, recursive) + viewActions.onFilterChange(it, recursive) }, getPossibleFilterValues = { - viewModel.getFilterOptionValues(it) + viewActions.getFilterOptionValues(it) }, filterOptions = filterOptions, onClickPlayAll = gridActions.onClickPlayAll!!, onClickShowViewOptions = { showViewOptions = true }, + onClickRandom = { + clickedRandom = true + viewActions.onClickRandom() + }, modifier = Modifier.focusRequester(headerRowFocusRequester), + onShowFilterDropdown = { filterDropdownShowing = it }, + filterButtonFocusRequester = filterButtonFocusRequester, + randomButtonFocusRequester = randomButtonFocusRequester, ) when (val pager = state.items) { @@ -839,16 +964,23 @@ fun CollectionFolderView( is DataLoadingState.Success<List<BaseItem?>> -> { LaunchedEffect(Unit) { - if (pager.data.isNotEmpty()) { - gridFocusRequester.tryRequestFocus() - } + val focusRequester = + when { + contextMenu.isShowing -> null + filterDropdownShowing -> filterButtonFocusRequester + clickedRandom -> randomButtonFocusRequester + pager.data.isNotEmpty() -> gridFocusRequester + else -> focusRequesterOnEmpty ?: headerRowFocusRequester + } + focusRequester?.tryRequestFocus() + clickedRandom = false } Box(Modifier.fillMaxSize()) { if (state.viewOptions.type == ViewOptionsType.GRID) { CollectionFolderGrid( preferences = preferences, collectionType = item?.data?.collectionType, - initialPosition = viewModel.position, + initialPosition = savedPosition, items = pager.data, sortAndDirection = state.sortAndDirection, modifier = Modifier.fillMaxSize(), @@ -856,12 +988,11 @@ fun CollectionFolderView( onClickItem = gridActions.onClickItem, onLongClickItem = gridActions.onLongClickItem!!, positionCallback = { columns, pos -> - viewModel.position = pos showHeader = pos < columns position = pos positionCallback?.invoke(columns, pos) }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, @@ -870,7 +1001,7 @@ fun CollectionFolderView( CollectionFolderList( preferences = preferences, collectionType = item?.data?.collectionType, - initialPosition = viewModel.position, + initialPosition = savedPosition, items = pager.data, sortAndDirection = state.sortAndDirection, modifier = Modifier.fillMaxSize(), @@ -878,12 +1009,11 @@ fun CollectionFolderView( onClickItem = gridActions.onClickItem, onLongClickItem = gridActions.onLongClickItem!!, positionCallback = { columns, pos -> - viewModel.position = pos showHeader = pos < columns position = pos positionCallback?.invoke(columns, pos) }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, @@ -912,50 +1042,16 @@ fun CollectionFolderView( } } } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - viewModel.serverRepository.currentUserDto - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = playlistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - playlistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - elevation = 3.dp, - ) - } + contextMenu.Compose() AnimatedVisibility(showViewOptions) { ViewOptionsDialog( viewOptions = state.viewOptions, defaultViewOptions = defaultViewOptions, onDismissRequest = { showViewOptions = false - viewModel.saveViewOptions(state.viewOptions) + viewActions.saveViewOptions(state.viewOptions) }, - onViewOptionsChange = viewModel::saveViewOptions, + onViewOptionsChange = viewActions::saveViewOptions, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index c41c27f0..2f39f238 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.supportedPlayableTypes +import com.github.damontecres.wholphin.util.supportedShufflableTypes import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -40,6 +41,7 @@ sealed interface ContextMenu { data class ForBaseItem( val fromLongClick: Boolean, val item: BaseItem, + val index: Int = UNSPECIFIED_INDEX, val chosenStreams: ChosenStreams?, val showGoTo: Boolean, val showStreamChoices: Boolean, @@ -47,6 +49,7 @@ sealed interface ContextMenu { val canRemoveContinueWatching: Boolean, val canRemoveNextUp: Boolean, val actions: ContextMenuActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForPerson( @@ -62,6 +65,7 @@ sealed interface ContextMenu { val canDelete: Boolean, val canRemoveFromQueue: Boolean, val actions: MusicContextActions, + val showRemoveFromPlaylist: Boolean = false, ) : ContextMenu data class ForQueue( @@ -70,6 +74,10 @@ sealed interface ContextMenu { val index: Int, val actions: QueueContextActions, ) : ContextMenu + + companion object { + const val UNSPECIFIED_INDEX = -12 + } } data class ContextMenuActions( @@ -86,6 +94,7 @@ data class ContextMenuActions( val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, val onClickAddToQueue: (BaseItem) -> Unit = {}, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class PersonContextActions( @@ -108,6 +117,7 @@ data class MusicContextActions( val onClickGoToArtist: (UUID) -> Unit = { navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) }, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, ) data class QueueContextActions( @@ -199,11 +209,7 @@ fun ContextMenu( seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, - showGoTo = contextMenu.showGoTo, - showStreamChoices = contextMenu.showStreamChoices, - canDelete = contextMenu.canDelete, - canRemoveContinueWatching = contextMenu.canRemoveContinueWatching, - canRemoveNextUp = contextMenu.canRemoveNextUp, + contextMenu = contextMenu, actions = actions, onChooseVersion = { chooseVersion = @@ -274,6 +280,7 @@ fun ContextMenu( onDismissRequest = { chooseVersion = null }, dismissOnClick = true, waitToLoad = params.fromLongClick, + elevation = 3.dp, ) } } @@ -285,6 +292,7 @@ fun ContextMenu( actions.onDeleteItem.invoke(item) onDismissRequest.invoke() }, + elevation = 3.dp, ) } if (showPlayWithDialog) { @@ -316,17 +324,13 @@ fun ContextMenu( private fun buildContextMenuItems( resources: Resources, + contextMenu: ContextMenu.ForBaseItem, item: BaseItem, seriesId: UUID?, sourceId: UUID?, watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, - showGoTo: Boolean, - showStreamChoices: Boolean, - canDelete: Boolean, - canRemoveContinueWatching: Boolean, - canRemoveNextUp: Boolean, actions: ContextMenuActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -336,7 +340,8 @@ private fun buildContextMenuItems( onClickPlayWith: () -> Unit, ): List<DialogItem> = buildList { - if (showGoTo) { + // Songs should not show Go to + if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) { add( DialogItem( resources.getString(R.string.go_to), @@ -353,7 +358,6 @@ private fun buildContextMenuItems( DialogItem( resources.getString(R.string.resume), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.navigateTo( @@ -383,7 +387,6 @@ private fun buildContextMenuItems( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.navigateTo( @@ -396,7 +399,23 @@ private fun buildContextMenuItems( ) } } - if (showStreamChoices) { + if (item.type in supportedShufflableTypes) { + add( + DialogItem( + resources.getString(R.string.shuffle), + R.string.fa_shuffle, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.PlaybackList( + itemId = item.id, + shuffle = true, + ), + ) + }, + ) + } + if (contextMenu.showStreamChoices) { item.data.mediaSources?.letNotEmpty { sources -> val source = sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } @@ -449,7 +468,7 @@ private fun buildContextMenuItems( } } } - if (item.type == BaseItemKind.MUSIC_ALBUM) { + if (item.type == BaseItemKind.MUSIC_ALBUM || item.type == BaseItemKind.AUDIO) { add( DialogItem( resources.getString(R.string.add_to_queue), @@ -460,6 +479,17 @@ private fun buildContextMenuItems( }, ) } + if (contextMenu.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(contextMenu.index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, @@ -469,7 +499,7 @@ private fun buildContextMenuItems( actions.onClickAddPlaylist.invoke(item.id) }, ) - if (canDelete) { + if (contextMenu.canDelete) { add( DialogItem( resources.getString(R.string.delete), @@ -481,7 +511,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { + if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { add( DialogItem( text = R.string.remove_continue_watching, @@ -492,7 +522,7 @@ private fun buildContextMenuItems( }, ) } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { add( DialogItem( text = R.string.remove_next_up, @@ -584,7 +614,7 @@ private fun buildContextMenuItems( }, ) } - if (showStreamChoices && canClearChosenStreams) { + if (contextMenu.showStreamChoices && canClearChosenStreams) { add( DialogItem( resources.getString(R.string.clear_track_choices), @@ -732,7 +762,6 @@ fun buildContextForMusic( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlay(index, item) @@ -742,7 +771,6 @@ fun buildContextForMusic( DialogItem( resources.getString(R.string.play_next), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlayNext(index, item) @@ -769,6 +797,17 @@ fun buildContextForMusic( }, ) } + if (music.showRemoveFromPlaylist) { + add( + DialogItem( + text = R.string.remove_from_playlist, + iconStringRes = R.string.fa_circle_xmark, + dismissOnClick = true, + ) { + actions.onRemoveFromPlaylist.invoke(index, item.id) + }, + ) + } add( DialogItem( text = R.string.add_to_playlist, @@ -865,7 +904,6 @@ fun buildContextForMusicQueue( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlay(index, item) @@ -875,7 +913,6 @@ fun buildContextForMusicQueue( DialogItem( resources.getString(R.string.play_next), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { actions.onClickPlayNext(index, item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt new file mode 100644 index 00000000..2261e0ac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt @@ -0,0 +1,166 @@ +package com.github.damontecres.wholphin.ui.components + +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +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.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import java.util.UUID + +/** + * Simplifies showing a basic context menu + */ +@Composable +fun rememberContextMenu( + preferences: UserPreferences, + provider: ContextMenuProvider, +): ContextMenuUtils = remember(preferences, provider) { ContextMenuUtils(preferences, provider) } + +class ContextMenuUtils( + private val preferences: UserPreferences, + private val provider: ContextMenuProvider, +) { + private var showContextMenu by mutableStateOf<Pair<Int, BaseItem>?>(null) + + val isShowing: Boolean get() = showContextMenu != null + + /** + * Show the context menu, typically from a long click + */ + fun showContextMenu( + position: Int, + item: BaseItem, + ) { + showContextMenu = Pair(position, item) + } + + /** + * Composes the context menu when needed + */ + @Composable + fun Compose(playlistViewModel: AddPlaylistViewModel = hiltViewModel()) { + var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = provider.isAdministrator(), + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { (position, item) -> + val contextActions = + remember { + ContextMenuActions( + navigateTo = provider::navigateTo, + onClickWatch = { itemId, watched -> + provider.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + provider.setFavorite(position, itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = provider::sendReportFor, + onDeleteItem = { provider.deleteItem(position, it) }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { result -> + // Not supported on this page + }, + onClearChosenStreams = { + // Not supported on this page + }, + onClickAddToQueue = playlistViewModel::addToQueue, + ) + } + val contextMenu = + remember { + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = provider.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + } + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } + } +} + +interface ContextMenuProvider { + fun isAdministrator(): Boolean + + fun navigateTo(destination: Destination) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean + + fun deleteItem( + index: Int, + item: BaseItem, + ) + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) + + fun sendReportFor(itemId: UUID) +} 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 6bd3dfaa..d7bd9951 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 @@ -63,19 +63,19 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.formatBitrate 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.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID @@ -85,7 +85,7 @@ import java.util.UUID data class DialogParams( val fromLongClick: Boolean, val title: String, - val items: List<DialogItem>, + val items: List<DialogItemEntry>, ) sealed interface DialogItemEntry @@ -319,30 +319,13 @@ fun DialogPopupContent( modifier = Modifier .focusRequester(focusRequesters[index]) - .ifElse( + .lazyListWrapScrolling( + listState, + focused, 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 - }, + focusRequesters[0], + focusRequesters[dialogItems.lastIndex], ), ) } @@ -630,7 +613,24 @@ fun chooseVersionParams( SelectedLeadingContent(uuid != null && uuid == chosenSourceId) }, supportingContent = { - videoStream?.displayTitle?.let { Text(text = it) } + val text = + remember { + buildList { + videoStream?.displayTitle?.let(::add) + source.bitrate?.let(::formatBitrate)?.let(::add) + }.joinToString(", ") + } + Text(text) + }, + trailingContent = { + val runtime = + remember { + source.runTimeTicks + ?.ticks + ?.roundMinutes + .toString() + } + Text(runtime) }, onClick = { onClick.invoke(index) }, ) @@ -655,8 +655,9 @@ fun chooseStream( type: MediaStreamType, preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, -): DialogParams = - DialogParams( +): DialogParams { + val filteredStreams = streams.filter { it.type == type } + return DialogParams( fromLongClick = false, title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), items = @@ -689,10 +690,12 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, ), ) + if (filteredStreams.isNotEmpty()) { + add(DialogItemDivider) + } } addAll( - streams - .filter { it.type == type } + filteredStreams .let { if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } @@ -708,7 +711,9 @@ fun chooseStream( }, headlineContent = { Text( - text = simpleStream.streamTitle ?: simpleStream.displayTitle, + text = + simpleStream.streamTitle + ?: simpleStream.displayTitle, ) }, supportingContent = { @@ -720,3 +725,4 @@ fun chooseStream( ) }, ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt index 59bb456f..b1995b3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ErrorMessage.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -10,30 +9,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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 com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Inject @HiltViewModel class ErrorViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val clientInfo: ClientInfo, - private val deviceInfo: DeviceInfo, + private val serverReportService: ServerReportService, ) : ViewModel() { fun sendLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index e881bb98..af64ef15 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -37,8 +37,20 @@ 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.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter +import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter +import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.FilterVideoType @@ -71,6 +83,7 @@ fun FilterByButton( onFilterChange: (GetItemsFilter) -> Unit, getPossibleValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, ) { var dropDown by remember { mutableStateOf(false) } var nestedDropDown by remember { mutableStateOf<ItemFilterBy<*>?>(null) } @@ -79,7 +92,10 @@ fun FilterByButton( Box(modifier = modifier) { ExpandableFilterButton( filterCount = filterCount, - onClick = { dropDown = true }, + onClick = { + onShow?.invoke(true) + dropDown = true + }, modifier = Modifier, ) @@ -87,6 +103,7 @@ fun FilterByButton( expanded = dropDown, containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), onDismissRequest = { + onShow?.invoke(false) dropDown = false nestedDropDown = null }, @@ -422,6 +439,286 @@ fun ExpandableFilterButton( } } +// TODO this all duplicated code from FilterByButton +@Composable +fun DiscoverFilterByButton( + filterOptions: List<DiscoverFilterBy<*>>, + current: DiscoverFilter, + onFilterChange: (DiscoverFilter) -> Unit, + getPossibleValues: suspend (DiscoverFilterBy<*>) -> List<FilterValueOption>, + modifier: Modifier = Modifier, + onShow: ((Boolean) -> Unit)? = null, +) { + var dropDown by remember { mutableStateOf(false) } + var nestedDropDown by remember { mutableStateOf<DiscoverFilterBy<*>?>(null) } + val filterCount = remember(current, filterOptions) { current.countFilters(filterOptions) } + + Box(modifier = modifier) { + ExpandableFilterButton( + filterCount = filterCount, + onClick = { + onShow?.invoke(true) + dropDown = true + }, + modifier = Modifier, + ) + + DropdownMenu( + expanded = dropDown, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + onDismissRequest = { + onShow?.invoke(false) + dropDown = false + nestedDropDown = null + }, + ) { + if (filterCount > 0) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(current.delete(filterOptions)) + dropDown = false + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + filterOptions + .forEachIndexed { index, filterOption -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + val currentValue = remember(current) { filterOption.get(current) } + TvDropdownMenuItem( + leadingIcon = { + if (currentValue != null) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + Text( + text = stringResource(filterOption.stringRes), + ) + }, + onClick = { + nestedDropDown = filterOption + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + + DropdownMenu( + expanded = nestedDropDown != null, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp), + offset = DpOffset(80.dp, 16.dp), + onDismissRequest = { + nestedDropDown = null + }, + ) { + nestedDropDown?.let { filterOption -> + filterOption as DiscoverFilterBy<Any> + val currentValue = remember(current) { filterOption.get(current) } + + var possibleValues by remember { mutableStateOf<List<FilterValueOption>?>(null) } + LaunchedEffect(Unit) { + possibleValues = getPossibleValues.invoke(filterOption) + } + + if (currentValue != null) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 5.dp, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(filterOption.set(null, current)) + nestedDropDown = null + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + + if (possibleValues == null) { + CircularProgress(Modifier.size(48.dp)) + } else if (possibleValues?.isEmpty() == true) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + possibleValues.orEmpty().forEachIndexed { index, value -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + + val isSelected = + remember(currentValue) { + when (filterOption) { + DiscoverMovieGenreFilter, + DiscoverTvGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverTvStudiosFilter, + -> { + (currentValue as? List<Int>) + .orEmpty() + .contains(value.value) + } + + DiscoverTvStatusFilter -> { + (currentValue as? List<TvShowStatus>) + .orEmpty() + .contains(value.value) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + (currentValue as? List<String>) + .orEmpty() + .contains(value.value) + } + + DiscoverRatingFilter -> { + (currentValue as? Int) == value.value + } + } + } + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 8.dp, + interactionSource = interactionSource, + leadingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + if (filterOption == DiscoverRatingFilter) { + SimpleStarRating( + "${value.name}+", + starColor = if (focused) EmptyStarColor else FilledStarColor, + ) + } else { + Text( + text = value.name, + ) + } + }, + onClick = { + val newFilter = + when (filterOption) { + DiscoverMovieGenreFilter, + DiscoverTvGenreFilter, + DiscoverMovieStudiosFilter, + DiscoverTvStudiosFilter, + -> { + val list = (currentValue as? List<Int>).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as Int) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverTvStatusFilter -> { + val list = + (currentValue as? List<TvShowStatus>).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as TvShowStatus) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverMovieContentRatingFilter, + DiscoverTvContentRatingFilter, + -> { + val list = (currentValue as? List<String>).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as String) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + DiscoverRatingFilter -> { + filterOption.set( + value.value as? Int, + current, + ) + } + } + + onFilterChange.invoke(newFilter) + if (!filterOption.supportMultiple) { + nestedDropDown = null + } + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + } + } + } +} + @PreviewTvSpec @Composable private fun ExpandableFilterButtonPreview() { 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 ac19cf30..ecc643fa 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 @@ -20,6 +20,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.alphabetSortName import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -33,13 +34,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 import kotlinx.coroutines.flow.MutableStateFlow @@ -51,6 +52,7 @@ import kotlinx.coroutines.withContext 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.api.ImageType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -103,7 +105,12 @@ class GenreViewModel .execute(api, request) .content.items .map { - Genre(it.id, it.name ?: "", null) + Genre( + id = it.id, + name = it.name ?: "", + imageUrl = null, + sortName = it.alphabetSortName, + ) } _state.update { it.copy( @@ -141,17 +148,18 @@ class GenreViewModel } suspend fun positionOfLetter(letter: Char): Int = - withContext(Dispatchers.IO) { - val request = - GetGenresRequest( - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ) - val result by GetGenresRequestHandler.execute(api, request) - return@withContext result.totalRecordCount + withContext(WholphinDispatchers.IO) { + GetGenresRequestHandler.countMatching( + api = api, + request = + GetGenresRequest( + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) } } @@ -196,7 +204,7 @@ suspend fun getGenreImageMap( val semaphore = Semaphore(4) genres .map { genreId -> - scope.async(Dispatchers.IO) { + scope.async(WholphinDispatchers.IO) { semaphore.withPermit { val item = GetItemsRequestHandler @@ -242,10 +250,10 @@ data class Genre( val id: UUID, val name: String, val imageUrl: String?, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } /** @@ -255,6 +263,7 @@ data class Genre( fun GenreCardGrid( itemId: UUID, includeItemTypes: List<BaseItemKind>?, + collectionType: CollectionType, modifier: Modifier = Modifier, initialPosition: Int = 0, viewModel: GenreViewModel = @@ -267,7 +276,7 @@ fun GenreCardGrid( val density = LocalDensity.current val configuration = LocalConfiguration.current val cardWidthPx = - remember { + remember(density) { with(density) { // Grid has 16dp padding on either side & 16dp spacing between 4 cards // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster @@ -308,6 +317,7 @@ fun GenreCardGrid( parentId = itemId, parentName = item.title, includeItemTypes = includeItemTypes, + collectionType = collectionType, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt index d74707e5..f86868bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt @@ -25,7 +25,7 @@ fun HiddenFocusBox( modifier = Modifier .fillMaxWidth() - .height(1.dp) + .height(0.dp) .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) onFocus.invoke() 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 65329026..3609cdfa 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 @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -7,49 +8,73 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.detail.CardGrid +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.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import timber.log.Timber +import java.util.UUID @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) class ItemGridViewModel @AssistedInject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, private val navigationManager: NavigationManager, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val serverReportService: ServerReportService, @Assisted private val destination: Destination.ItemGrid<*>, - ) : ViewModel() { - private val _state = MutableStateFlow(ItemGridState()) - val state: StateFlow<ItemGridState> = _state - + ) : ViewModel(), + ContextMenuProvider { @AssistedFactory interface Factory { fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel } + private val _state = MutableStateFlow(ItemGridState()) + val state: StateFlow<ItemGridState> = _state + init { viewModelScope.launchIO { try { @@ -77,9 +102,78 @@ class ItemGridViewModel } } - fun navigateTo(destination: Destination) { + override fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s", it) + 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", deletedItem.id) + showToast(context, "Error refreshing after item deleted") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.items as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) } data class ItemGridState( @@ -91,13 +185,17 @@ data class ItemGridState( */ @Composable fun ItemGrid( + preferences: UserPreferences, destination: Destination.ItemGrid<*>, modifier: Modifier = Modifier, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: ItemGridViewModel = hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>( creationCallback = { it.create(destination) }, ), ) { + val contextMenu = rememberContextMenu(preferences, viewModel) + val state by viewModel.state.collectAsState() when (val st = state.items) { is DataLoadingState.Error -> { @@ -111,20 +209,25 @@ fun ItemGrid( } is DataLoadingState.Success<List<BaseItem?>> -> { + var position by rememberInt(destination.initialPosition) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column(modifier = modifier) { GridTitle(destination.title.getString()) CardGrid( pager = st.data, onClickItem = { index: Int, item: BaseItem -> - // TODO handle more types - viewModel.navigateTo(Destination.Playback(item.id, 0)) + position = index + viewModel.navigateTo(item.destination(index)) + }, + onLongClickItem = { index: Int, item: BaseItem -> + position = index + contextMenu.showContextMenu(index, item) }, - onLongClickItem = { index: Int, item: BaseItem -> }, onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) }, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, @@ -148,4 +251,5 @@ fun ItemGrid( } } } + contextMenu.Compose() } 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 ca4c11b8..4137759d 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 @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh 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 @@ -42,6 +43,7 @@ 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.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -55,12 +57,15 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -81,10 +86,23 @@ fun ExpandablePlayButtons( moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onConfirmDelete: () -> Unit, + chooseVersionParams: ChooseVersionParams?, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current val firstFocus = remember { FocusRequester() } + val restartInteractionSource = remember { MutableInteractionSource() } + val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState() + LaunchedEffect(resumePosition) { + // Reset focus if the restart button was focuses and it is going to disappear + if (restartButtonFocused && resumePosition == Duration.ZERO) { + firstFocus.tryRequestFocus() + } + } + + var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } + LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(8.dp), @@ -93,19 +111,19 @@ fun ExpandablePlayButtons( .focusGroup() .focusRestorer(firstFocus), ) { + item("play") { + ExpandablePlayButton( + title = if (resumePosition > Duration.ZERO) R.string.resume else R.string.play, + resume = resumePosition, + icon = Icons.Default.PlayArrow, + onClick = playOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus), + ) + } if (resumePosition > Duration.ZERO) { - item("play") { - ExpandablePlayButton( - title = R.string.resume, - resume = resumePosition, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), - ) - } item("restart") { ExpandablePlayButton( title = R.string.restart, @@ -114,19 +132,31 @@ fun ExpandablePlayButtons( onClick = playOnClick, modifier = Modifier.onFocusChanged(buttonOnFocusChanged), mirrorIcon = true, + interactionSource = restartInteractionSource, ) } - } else { - item("play") { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = playOnClick, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), + } + + if (chooseVersionParams != null && chooseVersionParams.mediaSources.size > 1) { + item { + ExpandableFaButton( + title = R.string.version, + iconStringRes = R.string.fa_file_video, + onClick = { + chooseVersion = + chooseVersionParams( + resources, + chooseVersionParams.mediaSources.orEmpty(), + chooseVersionParams.chosenStreams + ?.source + ?.id + ?.toUUIDOrNull(), + ) { idx -> + val source = chooseVersionParams.mediaSources[idx] + chooseVersionParams.onChooseVersion.invoke(source) + } + }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), ) } } @@ -184,6 +214,16 @@ fun ExpandablePlayButtons( ) } } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } val MinButtonSize = 40.dp @@ -359,7 +399,7 @@ fun TrailerButton( } else if (trailers.size == 1) { R.string.play_trailer } else { - R.string.trailers + R.string.trailers_title }, iconStringRes = R.string.fa_film, enabled = trailers.isNotEmpty(), @@ -453,6 +493,7 @@ private fun ExpandablePlayButtonsPreview() { trailerOnClick = {}, canDelete = true, onConfirmDelete = {}, + chooseVersionParams = null, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index 2c30f337..6db89c75 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -26,9 +26,10 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.preferences.DisplayToggle import com.github.damontecres.wholphin.ui.dot -import com.github.damontecres.wholphin.ui.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization +import org.jellyfin.sdk.model.DateTime import kotlin.time.Duration @Composable @@ -37,6 +38,7 @@ fun QuickDetails( timeRemaining: Duration?, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, + endsAt: DateTime? = null, ) { val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val inlineContentMap = rememberQuickDetailsContentMap(textStyle) @@ -53,7 +55,11 @@ fun QuickDetails( QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) } } - timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } + if (timeRemaining != null) { + TimeRemaining(timeRemaining, textStyle = textStyle) + } else if (endsAt != null) { + EndsAt(endsAt, textStyle = textStyle) + } } } @@ -133,11 +139,25 @@ fun TimeRemaining( modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, ) { - val resources = LocalResources.current val now by LocalClock.current.now + EndsAt( + endsAt = now.plusSeconds(remaining.inWholeSeconds), + modifier = modifier, + textStyle = textStyle, + ) +} + +@Composable +fun EndsAt( + endsAt: DateTime, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val resources = LocalResources.current + val context = LocalContext.current val remainingStr = - remember(remaining, now, resources) { - val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) + remember(endsAt, resources, context) { + val endTimeStr = formatTime(context, endsAt) buildAnnotatedString { dot() append(resources.getString(R.string.ends_at, endTimeStr)) 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 88e86197..ca4ae7ff 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 @@ -22,9 +22,9 @@ 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.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.services.UserPreferencesService @@ -48,13 +48,13 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -62,7 +62,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber import java.util.UUID @@ -81,7 +80,7 @@ class RecommendedViewModel private val backdropService: BackdropService, private val mediaManagementService: MediaManagementService, private val suggestionService: SuggestionService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, @Assisted private val parentId: UUID, @Assisted private val suggestionsType: BaseItemKind, @Assisted private val recommendedRows: List<RecommendedRow<*>>, @@ -120,10 +119,12 @@ class RecommendedViewModel viewModelScope.launchIO { val result = try { + val items = execute(row, limit) HomeRowLoadingState.Success( title, - execute(row, limit), + items, viewOptions, + showViewMore = items.size >= limit, ) } catch (ex: Exception) { Timber.e(ex, "Exception fetching %s", title) @@ -171,7 +172,11 @@ class RecommendedViewModel } private fun fetchSuggestions() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(WholphinDispatchers.IO) { + val limit = + userPreferencesService + .getCurrent() + .appPreferences.homePagePreferences.maxItemsPerRow val title = ResStringProvider(R.string.suggestions) try { suggestionService @@ -188,6 +193,7 @@ class RecommendedViewModel title, resource.items, viewOptions, + showViewMore = resource.items.size >= limit, ) } @@ -196,6 +202,7 @@ class RecommendedViewModel title, emptyList(), viewOptions, + showViewMore = false, ) } } @@ -380,10 +387,10 @@ fun RecommendedContent( viewModel.setFavorite(position, itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(position, it) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { _, _ -> @@ -477,12 +484,8 @@ fun RecommendedContent( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } } - -data class RowColumnItem( - val position: RowColumn, - val item: BaseItem, -) 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 f87f5c98..9021e213 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 @@ -146,6 +146,8 @@ fun sliderActiveColor(focused: Boolean): Color { AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.border } @@ -188,6 +190,8 @@ fun sliderInactiveColor(focused: Boolean): Color { AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .66f) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt index 53511a3a..3e2555db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.flip @@ -115,3 +117,94 @@ fun SortAndDirection.toAnnotatedString() = append(" ") append(stringResource(getStringRes(sort))) } + +// TODO code duplicated from SortByButton +@Composable +fun DiscoverSortByButton( + sortOptions: List<DiscoverSort>, + current: DiscoverSortAndDirection, + onSortChange: (DiscoverSortAndDirection) -> Unit, + modifier: Modifier = Modifier, +) { + val currentSort = current.sort + val currentDirection = current.direction + var sortByDropDown by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + TextButton( + onClick = { sortByDropDown = true }, + onLongClick = { + onSortChange.invoke(current.flip()) + }, + ) { + Text( + text = current.toAnnotatedString(), + ) + } + + DropdownMenu( + expanded = sortByDropDown, + onDismissRequest = { sortByDropDown = false }, + ) { + sortOptions +// .sortedBy { it.name } + .forEach { sortOption -> + TvDropdownMenuItem( + leadingIcon = { + if (sortOption == currentSort) { + if (currentDirection == SortOrder.ASCENDING) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } else { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) + } + } + }, + text = { + Text( + text = stringResource(sortOption.stringRes), + ) + }, + onClick = { + sortByDropDown = false + val newDirection = + if (currentSort == sortOption) { + currentDirection.flip() + } else { + currentDirection + } + onSortChange.invoke( + DiscoverSortAndDirection( + sortOption, + newDirection, + ), + ) + }, + ) + } + } + } +} + +@Composable +fun DiscoverSortAndDirection.toAnnotatedString() = + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = FontAwesome)) { + append( + stringResource( + if (direction == SortOrder.ASCENDING) { + R.string.fa_caret_up + } else { + R.string.fa_caret_down + }, + ), + ) + } + append(" ") + append(stringResource(sort.stringRes)) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index cf3e7ed9..9ff0e2c0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -20,6 +20,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.alphabetSortName import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -32,11 +33,11 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -97,7 +98,12 @@ class StudioViewModel imageType = ImageType.THUMB, fillWidth = cardWidthPx, ) - Studio(it.id, it.name ?: "", imageUrl) + Studio( + id = it.id, + name = it.name ?: "", + imageUrl = imageUrl, + sortName = it.alphabetSortName, + ) } _state.update { it.copy( @@ -113,18 +119,19 @@ class StudioViewModel } suspend fun positionOfLetter(letter: Char): Int = - withContext(Dispatchers.IO) { - val request = - GetStudiosRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ) - val result by GetStudiosRequestHandler.execute(api, request) - return@withContext result.totalRecordCount + withContext(WholphinDispatchers.IO) { + GetStudiosRequestHandler.countMatching( + api = api, + request = + GetStudiosRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) } } @@ -133,10 +140,10 @@ data class Studio( val id: UUID, val name: String, val imageUrl: String?, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } data class StudioGridState( @@ -160,7 +167,7 @@ fun StudioCardGrid( val density = LocalDensity.current val configuration = LocalConfiguration.current val cardWidthPx = - remember { + remember(density) { with(density) { // Grid has 16dp padding on either side & 16dp spacing between 4 cards // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index f54fda5b..f2f60ad8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,7 +31,6 @@ 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.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned @@ -42,26 +43,27 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import timber.log.Timber +import java.util.SortedMap @Composable fun TabRow( selectedTabIndex: Int, - tabs: List<String>, - focusRequesters: List<FocusRequester>, + tabs: List<TabDetails>, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() LaunchedEffect(selectedTabIndex) { - if (selectedTabIndex >= 0) { + if (selectedTabIndex in tabs.indices) { state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } } var rowHasFocus by remember { mutableStateOf(false) } val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) - val currentFocusRequesters by rememberUpdatedState(focusRequesters) +// val currentFocusRequesters by rememberUpdatedState(focusRequesters) val currentOnClick by rememberUpdatedState(onClick) LazyRow( @@ -76,21 +78,21 @@ fun TabRow( // If entering from left or right, use last or first tab // Otherwise use the selected tab val index = currentSelectedTabIndex - val requesters = currentFocusRequesters +// val requesters = currentFocusRequesters Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") - val focusRequester = + val tab = if (requestedFocusDirection == FocusDirection.Left) { - requesters.lastOrNull() + tabs.lastOrNull() } else if (requestedFocusDirection == FocusDirection.Right) { - requesters.firstOrNull() + tabs.firstOrNull() } else { - requesters.getOrNull(index) + tabs.getOrNull(index) } - (focusRequester ?: FocusRequester.Default).tryRequestFocus() + (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() } }, ) { - itemsIndexed(tabs) { index, tabTitle -> + itemsIndexed(tabs) { index, tab -> val interactionSource = remember { MutableInteractionSource() } val onTabClick = remember(index) { @@ -99,17 +101,90 @@ fun TabRow( } } Tab( - title = tabTitle, + title = tab.title.getString(), selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, onClick = onTabClick, - modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }), + modifier = Modifier.focusRequester(tab.tabFocusRequester), ) } } } +@Composable +fun <T> KeyedTabRow( + selectedTabKey: T, + tabs: SortedMap<T, TabDetails>, + onClick: (T) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + LaunchedEffect(selectedTabKey) { + tabs[selectedTabKey]?.bringIntoViewRequester?.bringIntoView() + } + var rowHasFocus by remember { mutableStateOf(false) } + val currentSelectedTabKey by rememberUpdatedState(selectedTabKey) + val currentOnClick by rememberUpdatedState(onClick) + + val tabKeyList = remember(tabs) { tabs.keys.filterNotNull().toList() } + + LazyRow( + state = state, + modifier = + modifier + .onFocusChanged { + rowHasFocus = it.hasFocus + }.focusGroup() + .focusProperties { + onEnter = { + // If entering from left or right, use last or first tab + // Otherwise use the selected tab + val currentKey = currentSelectedTabKey +// val requesters = currentFocusRequesters + Timber.v( + "onEnter requestedFocusDirection=%s, currentSelectedTabKey=%s", + requestedFocusDirection, + currentSelectedTabKey, + ) + val key = + if (requestedFocusDirection == FocusDirection.Left) { + tabs.lastKey() + } else if (requestedFocusDirection == FocusDirection.Right) { + tabs.firstKey() + } else { + currentKey + } + val tab = tabs[key] + (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() + } + }, + ) { + items(tabKeyList, key = { it }) { tabKey -> + tabs[tabKey]?.let { tab -> + val interactionSource = remember { MutableInteractionSource() } + val onTabClick = + remember(tabKey) { + { + currentOnClick(tabKey) + } + } + Tab( + title = tab.title.getString(), + selected = tabKey == selectedTabKey, + rowActive = rowHasFocus, + interactionSource = interactionSource, + onClick = onTabClick, + modifier = + Modifier + .focusRequester(tab.tabFocusRequester) + .bringIntoViewRequester(tab.bringIntoViewRequester), + ) + } + } + } +} + @Composable fun Tab( title: String, @@ -195,9 +270,11 @@ private fun TabRowPreview() { WholphinTheme { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { TabRow( - selectedTabIndex = 1, - tabs = listOf("Tab 1", "Tab 2", "Tab 3"), - focusRequesters = listOf(), + selectedTabIndex = -1, + tabs = + remember { + listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) } + }, onClick = {}, ) Tab( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt new file mode 100644 index 00000000..0749c068 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -0,0 +1,241 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.util.SortedMap + +@HiltViewModel(assistedFactory = TabViewModel.Factory::class) +class TabViewModel + @AssistedInject + constructor( + val userPreferencesService: UserPreferencesService, + private val rememberedTabService: RememberedTabService, + private val backdropService: BackdropService, + @param:Assisted private val itemId: String, + @param:Assisted private val tabCount: Int, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + itemId: String, + tabCount: Int, + ): TabViewModel + } + + private val _state = MutableStateFlow<Int>(UNSET) + val state: StateFlow<Int> = _state + + val isShowClock = + userPreferencesService.flow.map { it.appPreferences.interfacePreferences.showClock } + + init { + viewModelScope.launchIO { + val startingTab = + if (shouldRememberTabs()) { + Timber.v("Getting remembered tab for %s", itemId) + try { + rememberedTabService + .getRememberedTab(itemId) + ?.coerceIn(0..<tabCount) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tab for %s", itemId) + null + } ?: 0 + } else { + 0 + } + _state.value = startingTab + } + } + + private suspend fun shouldRememberTabs(): Boolean = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + + fun updateSelectedTabIndex(tabIndex: Int) { + _state.value = tabIndex + viewModelScope.launchIO { + backdropService.clearBackdrop() + if (shouldRememberTabs()) { + Timber.v("Saving remembered tab for %s: %s", itemId, tabIndex) + rememberedTabService.saveRememberedTab(itemId, tabIndex) + } + } + } + } + +data class TabDetails( + val title: StringProvider, + val tabFocusRequester: FocusRequester = FocusRequester(), + val contentFocusRequester: FocusRequester = FocusRequester(), + val bringIntoViewRequester: BringIntoViewRequester = BringIntoViewRequester(), +) { + constructor( + @StringRes stringResId: Int, + ) : this(ResStringProvider(stringResId)) +} + +const val UNSET = -1234 + +/** + * A page showing multiple tabs + * + * This handles remembering the selected tabs via [TabViewModel] if needed + */ +@Composable +fun TabbedPage( + itemId: String, + tabs: List<TabDetails>, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + viewModel: TabViewModel = + hiltViewModel<TabViewModel, TabViewModel.Factory>( + key = "$itemId-${tabs.size}", + creationCallback = { it.create(itemId, tabs.size) }, + ), + tabContent: @Composable (Int, TabDetails) -> Unit, +) { + val selectedTabIndex by viewModel.state.collectAsState() + val isShowClock by viewModel.isShowClock.collectAsState(true) + TabbedPage( + selectedTabIndex = selectedTabIndex, + tabs = tabs, + updateSelectedTabIndex = viewModel::updateSelectedTabIndex, + isShowClock = isShowClock, + modifier = modifier, + showTabs = showTabs, + tabContent = tabContent, + ) +} + +@Composable +fun TabbedPage( + selectedTabIndex: Int, + tabs: List<TabDetails>, + updateSelectedTabIndex: (Int) -> Unit, + isShowClock: Boolean, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + tabContent: @Composable (Int, TabDetails) -> Unit, +) { + val endPadding = + remember(isShowClock) { + if (isShowClock) { + 184.dp + } else { + 0.dp + } + } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(top = 16.dp, bottom = 16.dp, end = endPadding), + tabs = tabs, + onClick = updateSelectedTabIndex, + ) + } + selectedTabIndex.let { tabIndex -> + if (tabIndex in tabs.indices) { + tabContent.invoke(tabIndex, tabs[tabIndex]) + } else { + DelayedLoadingPage(focusEnabled = false) + } + } + } +} + +@Composable +fun <T> KeyedTabbedPage( + selectedTabKey: T, + tabs: SortedMap<T, TabDetails>, + updateSelectedTabKey: (T) -> Unit, + isShowClock: Boolean, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + tabContent: @Composable (T, TabDetails) -> Unit, +) { + val endPadding = + remember(isShowClock) { + if (isShowClock) { + 184.dp + } else { + 0.dp + } + } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + KeyedTabRow( + selectedTabKey = selectedTabKey, + modifier = + Modifier + .padding(top = 16.dp, bottom = 16.dp, end = endPadding), + tabs = tabs, + onClick = updateSelectedTabKey, + ) + } + AnimatedContent( + targetState = selectedTabKey, + transitionSpec = { + (fadeIn(animationSpec = tween(300, delayMillis = 100))) + .togetherWith(fadeOut(animationSpec = tween(200))) + }, + ) { tabKey -> + val tab = tabs[tabKey] + if (tab != null) { + tabContent.invoke(tabKey, tab) + } else { + DelayedLoadingPage(focusEnabled = false) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt index ff001d0b..99288b9a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -21,6 +21,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.logCoilError import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -40,6 +41,10 @@ fun TitleOrLogo( model = logoImageUrl, contentDescription = title, contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, modifier = Modifier .height(HeaderUtils.logoHeight) @@ -113,6 +118,10 @@ fun SimpleTitleOrLogo( model = logoImageUrl, contentDescription = item?.title, contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, modifier = Modifier.fillMaxSize(), ) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index 4f4d7588..a1237acd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -4,8 +4,11 @@ import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -37,6 +41,7 @@ import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.preferences.ComposablePreference import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType /** @@ -74,45 +79,52 @@ fun ViewOptionsDialog( ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS } } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), + Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier .width(256.dp) .heightIn(max = 380.dp) - .focusRequester(focusRequester) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), shape = RoundedCornerShape(8.dp), ), ) { - stickyHeader { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - items(options, key = { it.title }) { pref -> - pref as AppPreference<ViewOptions, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(viewOptions) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - onClickPreference = { pref -> - if (pref == ViewOptions.ViewOptionsReset) { - onViewOptionsChange.invoke(defaultViewOptions) - } - }, - ) + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(options, key = { it.title }) { pref -> + pref as AppPreference<ViewOptions, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + if (pref == ViewOptions.ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } } } } @@ -302,3 +314,27 @@ enum class ViewOptionsType { LIST, DENSE_LIST, } + +val BaseItemKind.defaultViewOptions: ViewOptions + get() = + when (this) { + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.PLAYLIST, + -> ViewOptionsSquare + + BaseItemKind.EPISODE, + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> ViewOptionsWide + + else -> ViewOptionsPoster + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt index d2d6fa32..57874b0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt @@ -17,10 +17,10 @@ import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import androidx.core.content.ContextCompat import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher @@ -175,7 +175,7 @@ class VoiceInputManager private fun provideMainDispatcher(): CoroutineDispatcher = try { - Dispatchers.Main.immediate + WholphinDispatchers.Main.immediate } catch (_: IllegalStateException) { // Fallback for unit tests where Main dispatcher is not installed handler.asCoroutineDispatcher() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt index c2b8c94a..4dc1dd16 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -5,15 +5,19 @@ import android.widget.Toast 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.services.MusicService import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.music.addToQueue +import com.github.damontecres.wholphin.ui.launchDefault 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.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.api.client.ApiClient import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -27,17 +31,19 @@ class AddPlaylistViewModel @Inject constructor( @param:ApplicationContext private val context: Context, + private val api: ApiClient, private val playlistCreator: PlaylistCreator, + private val musicService: MusicService, ) : ViewModel() { val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending) - fun loadPlaylists(mediaType: MediaType?) { + fun loadPlaylists(query: String = "") { viewModelScope.launchIO { this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading try { - val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) + val playlists = playlistCreator.getServerPlaylists(query, null) this@AddPlaylistViewModel.playlistState.value = - PlaylistLoadingState.Success(playlists) + PlaylistLoadingState.Success(playlists, query) } catch (ex: Exception) { playlistState.value = PlaylistLoadingState.Error(ex) } @@ -72,4 +78,10 @@ class AddPlaylistViewModel } } } + + fun addToQueue(item: BaseItem) { + viewModelScope.launchDefault { + addToQueue(api, musicService, item, -1) + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt new file mode 100644 index 00000000..397a2126 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt @@ -0,0 +1,13 @@ +package com.github.damontecres.wholphin.ui.data + +import com.github.damontecres.wholphin.data.ChosenStreams +import org.jellyfin.sdk.model.api.MediaSourceInfo + +/** + * Necessary details & action for choosing a version of some content + */ +data class ChooseVersionParams( + val chosenStreams: ChosenStreams?, + val mediaSources: List<MediaSourceInfo>, + val onChooseVersion: (MediaSourceInfo) -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index f0591868..08bd0c55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec @@ -153,7 +154,7 @@ fun ItemDetailsDialog( ) } source.runTimeTicks?.let { - add(runtimeLabel to it.ticks.toString()) + add(runtimeLabel to resources.formatDuration(it.ticks)) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt index 30edee62..36b4e5c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/SortAndDirection.kt @@ -1,8 +1,12 @@ package com.github.damontecres.wholphin.ui.data import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.github.damontecres.wholphin.R import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -44,8 +48,9 @@ val SeriesSortOptions = ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_LAST_CONTENT_ADDED, - ItemSortBy.DATE_PLAYED, + ItemSortBy.SERIES_DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RANDOM, ) @@ -53,19 +58,27 @@ val SeriesSortOptions = val EpisodeSortOptions = listOf( ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_PLAYED, ItemSortBy.AIRED_EPISODE_ORDER, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, + ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, ) val VideoSortOptions = listOf( ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, @@ -80,6 +93,7 @@ val PlaylistSortOptions = ItemSortBy.DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, ItemSortBy.CRITIC_RATING, + ItemSortBy.OFFICIAL_RATING, ItemSortBy.RUNTIME, ItemSortBy.PLAY_COUNT, ItemSortBy.RANDOM, @@ -180,3 +194,31 @@ fun getStringRes(sort: ItemSortBy): Int = else -> throw IllegalArgumentException("Unsupported sort option: $sort") } + +@Composable +fun rememberSortOptions(collectionType: CollectionType): List<ItemSortBy> = + remember(collectionType) { + when (collectionType) { + CollectionType.MOVIES -> MovieSortOptions + CollectionType.TVSHOWS -> SeriesSortOptions + CollectionType.MUSIC -> AlbumSortOptions + CollectionType.BOXSETS -> BoxSetSortOptions + else -> VideoSortOptions + } + } + +@Composable +fun rememberSortOptions(type: BaseItemKind): List<ItemSortBy> = + remember(type) { + when (type) { + BaseItemKind.MOVIE -> MovieSortOptions + BaseItemKind.SERIES -> SeriesSortOptions + BaseItemKind.SEASON -> SeriesSortOptions + BaseItemKind.EPISODE -> EpisodeSortOptions + BaseItemKind.AUDIO -> SongSortOptions + BaseItemKind.MUSIC_ALBUM -> AlbumSortOptions + BaseItemKind.MUSIC_ARTIST -> ArtistSortOptions + BaseItemKind.BOX_SET -> BoxSetSortOptions + else -> VideoSortOptions + } + } 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 1d42848b..00713ddb 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 @@ -73,7 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -141,7 +141,7 @@ fun <T : CardGridItem> CardGrid( { index: Int -> if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index") if (index != currentFocusedIndex) { - previouslyFocusedIndex = focusedIndex + previouslyFocusedIndex = currentFocusedIndex } focusedIndex = index } @@ -217,7 +217,7 @@ fun <T : CardGridItem> CardGrid( { letter: Char -> scope.launch(ExceptionHandler()) { val jumpPosition = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { letterPosition.invoke(letter) } Timber.d("Alphabet jump to $jumpPosition") @@ -257,10 +257,11 @@ fun <T : CardGridItem> CardGrid( val newPosition = previouslyFocusedIndex if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") if (newPosition > 0) { - focusOn(newPosition) scope.launch(ExceptionHandler()) { - gridState.scrollToItem(newPosition, -columns) - firstFocus.tryRequestFocus() + pager.getOrNull(newPosition) + gridState.scrollToItem(newPosition) + focusOn(newPosition) + alphabetFocus = true } } return@onKeyEvent true @@ -344,7 +345,7 @@ fun <T : CardGridItem> CardGrid( items(pager.size) { index -> val item = pager[index] val details = - remember(index, item) { + remember(index, item, cardWidthPx, columns) { val mod = if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") @@ -514,7 +515,7 @@ fun AlphabetButtons( val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val index = letters.indexOf(currentLetter) + val index = remember(letters, currentLetter) { letters.indexOf(currentLetter) } LaunchedEffect(currentLetter) { scope.launch(ExceptionHandler()) { val firstVisibleItemIndex = listState.firstVisibleItemIndex @@ -522,7 +523,7 @@ fun AlphabetButtons( listState.layoutInfo.visibleItemsInfo .lastOrNull() ?.index ?: -1 - if (index !in firstVisibleItemIndex..lastVisibleItemIndex) { + if (index >= 0 && index !in firstVisibleItemIndex..lastVisibleItemIndex) { listState.animateScrollToItem(index) } } 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 7d5c262c..ccd7e556 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable 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.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -32,7 +33,7 @@ fun CollectionFolderGeneric( sortOptions: List<ItemSortBy> = VideoSortOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } val viewOptions = remember(usePosters) { if (usePosters) { 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 f1e575b3..18af298b 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,25 +1,15 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -32,20 +22,23 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.DataLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -56,24 +49,35 @@ class LiveTvCollectionViewModel val api: ApiClient, val serverRepository: ServerRepository, val navigationManager: NavigationManager, - val rememberTabManager: RememberTabManager, val backdropService: BackdropService, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - val recordingFolders = MutableStateFlow<List<TabId>>(emptyList()) + ) : ViewModel() { + val state = MutableStateFlow(LiveTvRecordingFolderState()) init { viewModelScope.launchIO { - val folders = - api.liveTvApi - .getRecordingFolders(userId = serverRepository.currentUser?.id) - .content.items - .map { TabId(it.name ?: "Recordings", it.id) } - recordingFolders.value = folders + try { + val folders = + api.liveTvApi + .getRecordingFolders(userId = serverRepository.currentUser?.id) + .content.items + .map { TabId(it.name ?: "Recordings", it.id) } + state.value = + LiveTvRecordingFolderState(DataLoadingState.Success(folders)) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching recording folders") + state.value = + LiveTvRecordingFolderState(DataLoadingState.Error(ex)) + } } } } +data class LiveTvRecordingFolderState( + val folders: DataLoadingState<List<TabId>> = DataLoadingState.Pending, +) + data class TabId( val title: String, val id: UUID, @@ -86,102 +90,91 @@ fun CollectionFolderLiveTv( modifier: Modifier = Modifier, viewModel: LiveTvCollectionViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val folders by viewModel.recordingFolders.collectAsState() + val state by viewModel.state.collectAsState() - val tvGuideStr = stringResource(R.string.tv_guide) - val tvDvrStr = stringResource(R.string.tv_dvr_schedule) - val tabs = - remember(folders) { - listOf( - TabId(tvGuideStr, UUID.randomUUID()), - TabId(tvDvrStr, UUID.randomUUID()), - ) + folders + when (val st = state.folders) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("livetv", selectedTabIndex) - viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - viewModel.backdropService.clearBackdrop() - } - val onClickItem = { position: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs.map { it.title }, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) } - when (selectedTabIndex) { - 0 -> { - TvGuideGrid( - onRowPosition = { - showHeader = it <= 0 - }, - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } - 1 -> { - DvrSchedule( - requestFocusAfterLoading = true, - focusRequesterOnEmpty = tabFocusRequesters[1], - modifier = - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } + is DataLoadingState.Success<List<TabId>> -> { + val tabs = + remember { + listOf( + TabDetails(R.string.tv_guide), + TabDetails(R.string.tv_dvr_schedule), + ) + + st.data.map { + TabDetails(StringStringProvider(it.title)) + } + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } - else -> { - val folderIndex = selectedTabIndex - 2 - if (folderIndex in folders.indices) { - CollectionFolderView( - preferences = preferences, - onClickItem = onClickItem, - itemId = folders[folderIndex].id, - initialFilter = CollectionFolderFilter(), - showTitle = false, - recursive = false, - sortOptions = VideoSortOptions, - modifier = Modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - defaultViewOptions = ViewOptions(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } else { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + var showHeader by rememberSaveable { mutableStateOf(true) } + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { + 0 -> { + TvGuideGrid( + preferences = preferences, + onRowPosition = { + showHeader = it <= 0 + }, + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + 1 -> { + DvrSchedule( + requestFocusAfterLoading = true, + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + else -> { + val folderIndex = tabIndex - 2 + if (folderIndex in st.data.indices) { + CollectionFolderView( + preferences = preferences, + onClickItem = onClickItem, + itemId = st.data[folderIndex].id, + initialFilter = CollectionFolderFilter(), + showTitle = false, + recursive = false, + sortOptions = VideoSortOptions, + modifier = Modifier.focusRequester(tabDetails.contentFocusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + defaultViewOptions = ViewOptions(), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + ) + } else { + ErrorMessage("Invalid tab index $tabIndex", null) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 564ac66a..cb1d1812 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,24 +1,14 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.CollectionFolderFilter @@ -28,14 +18,15 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedMovie -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType @Composable fun CollectionFolderMovie( @@ -44,51 +35,25 @@ fun CollectionFolderMovie( modifier: Modifier = Modifier, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.collections), - stringResource(R.string.genres), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } -// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("movie", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.collections), + TabDetails(R.string.genres), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMovie( @@ -100,7 +65,7 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -127,12 +92,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -159,12 +124,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -173,15 +138,16 @@ fun CollectionFolderMovie( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.MOVIE), + collectionType = CollectionType.MOVIES, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 421e7ad3..9ab7e33f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -1,24 +1,14 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,6 +17,7 @@ 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.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.MusicService @@ -36,23 +27,23 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.RecommendedMusic -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.data.AlbumSortOptions import com.github.damontecres.wholphin.ui.data.ArtistSortOptions import com.github.damontecres.wholphin.ui.data.SongSortOptions import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID @@ -65,10 +56,8 @@ class CollectionFolderMusicViewModel private val musicService: MusicService, private val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, @Assisted private val itemId: UUID, - ) : ViewModel(), - RememberTabManager by rememberTabManager { + ) : ViewModel() { @AssistedFactory interface Factory { fun create(itemId: UUID): CollectionFolderMusicViewModel @@ -86,13 +75,7 @@ class CollectionFolderMusicViewModel index: Int, item: BaseItem, ) { - if (item.type == BaseItemKind.AUDIO) { - viewModelScope.launchDefault { - musicService.setQueue(listOf(item), false) - } - } else { - navigationManager.navigateTo(item.destination()) - } + navigationManager.navigateTo(item.destination()) } fun onClickPlayAll(shuffle: Boolean) { @@ -131,30 +114,16 @@ fun CollectionFolderMusic( creationCallback = { it.create(destination.itemId) }, ), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.albums), - stringResource(R.string.artists), - stringResource(R.string.genres), - stringResource(R.string.songs), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } -// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("music", selectedTabIndex) - viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - viewModel.backdropService.clearBackdrop() - } - + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.albums), + TabDetails(R.string.artists), + TabDetails(R.string.genres), + TabDetails(R.string.songs), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } val actions = @@ -166,27 +135,13 @@ fun CollectionFolderMusic( onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, ) } - - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMusic( @@ -198,7 +153,7 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -223,12 +178,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -243,7 +198,7 @@ fun CollectionFolderMusic( CollectionFolderFilter( filter = GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), + override = GetItemsFilterOverride.ARTIST, ), ), showTitle = false, @@ -253,12 +208,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -267,10 +222,11 @@ fun CollectionFolderMusic( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + collectionType = CollectionType.MUSIC, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -295,17 +251,17 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index ce4c7ec8..02ecfd9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -5,6 +5,7 @@ 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.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -48,7 +49,7 @@ fun CollectionFolderPhotoAlbum( ) }, ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } val state by viewModel.state.collectAsState() CollectionFolderView( preferences = preferences, 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 30a40fcd..70acd69b 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 @@ -3,7 +3,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,7 +24,7 @@ fun CollectionFolderPlaylist( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, 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 0279bd72..95fcaf00 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 @@ -3,7 +3,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,7 +24,7 @@ fun CollectionFolderRecordings( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by remember { mutableStateOf(true) } + var showHeader by rememberSaveable { mutableStateOf(true) } CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, 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 af75b638..2f8fb909 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,101 +1,77 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions 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.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.StudioCardGrid -import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import javax.inject.Inject + +@HiltViewModel +class CollectionFolderTvViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + ) : ViewModel() @Composable fun CollectionFolderTv( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: CollectionFolderTvViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.genres), - stringResource(R.string.studios), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("tv", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } - - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.genres), + TabDetails(R.string.studios), + ) + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedTvShow( @@ -107,7 +83,7 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -131,15 +107,13 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, + onClickItem = onClickItem, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -148,10 +122,11 @@ fun CollectionFolderTv( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.SERIES), + collectionType = CollectionType.TVSHOWS, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -163,12 +138,12 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } 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 0f859bb8..7c8268b0 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 @@ -2,6 +2,10 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import android.hardware.display.DisplayManager +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager import android.os.Build import android.util.Log import android.view.Display @@ -12,11 +16,14 @@ 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.LazyColumn +import androidx.compose.foundation.lazy.items 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.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -25,7 +32,6 @@ 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.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -33,23 +39,23 @@ 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.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong -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 java.util.Date @@ -59,17 +65,18 @@ import javax.inject.Inject class DebugViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { + val state = MutableStateFlow(DebugState()) val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList()) val logcat = MutableStateFlow<List<LogcatLine>>(emptyList()) val supportedModes by lazy { - val displayManager = - MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) display.supportedModes.orEmpty() } @@ -103,6 +110,59 @@ class DebugViewModel } init { + viewModelScope.launchDefault { + val buildTime = Date(BuildConfig.BUILD_TIME) + val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val installInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val installSource = + context.packageManager.getInstallSourceInfo(context.packageName) + buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add("Install source: ${installSource.packageSource}") + } + add("Installer: ${installSource.installingPackageName}") + add("Initiator: ${installSource.initiatingPackageName}") + } + } else { + listOf( + context.packageManager + .getInstallerPackageName(context.packageName) + .toString(), + ) + } + + val appInfo = + listOf( + "Version Name: ${pkgInfo.versionName}", + "Version Code: ${pkgInfo.versionCodeLong}", + "ClientInfo: $clientInfo", + "Build type: ${BuildConfig.BUILD_TYPE}", + "Build flavor: ${BuildConfig.FLAVOR}", + "Build time: $buildTime", + "FFMPEG included: $ffmpegIncluded", + "AV1 included: $av1Included", + "libmpv loaded: $libMpvLoaded", + "Debug enabled: ${BuildConfig.DEBUG}", + "ABIs: ${Build.SUPPORTED_ABIS.toList()}", + ) + installInfo + state.update { it.copy(appInfo = appInfo) } + } + viewModelScope.launchDefault { + val deviceInfoList = + listOf( + "DeviceInfo: $deviceInfo", + "Manufacturer: ${Build.MANUFACTURER}", + "Model: ${Build.MODEL}", + "API Level: ${Build.VERSION.SDK_INT}", + ) + state.update { + it.copy( + deviceInfo = deviceInfoList, + displayModes = supportedModes.map { it.toString() }, + ) + } + } viewModelScope.launchIO { serverRepository.currentUser?.rowId?.let { val results = itemPlaybackDao.getItems(it) @@ -113,74 +173,84 @@ class DebugViewModel val logcat = getLogCatLines() this@DebugViewModel.logcat.value = logcat } + viewModelScope.launchDefault { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val callback = + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array<AudioDeviceInfo>) { + state.update { + it.copy( + audioInfo = + it.audioInfo.toMutableList().apply { + addAll( + addedDevices + .filter { it.isSink } + .map { it.details }, + ) + }, + ) + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) { + state.update { + it.copy( + audioInfo = + it.audioInfo.toMutableList().apply { + removeAll(removedDevices.map { it.details }) + }, + ) + } + } + } + audioManager.registerAudioDeviceCallback(callback, null) + addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } + } } companion object { - fun getLogCatLines(): List<LogcatLine> { - val lineCount = 500 - val args = - buildList { - add("logcat") - add("-d") - add("-t") - add(lineCount.toString()) - addAll(THIRD_PARTY_TAGS) - add("*:V") - } - val process = ProcessBuilder().command(args).redirectErrorStream(true).start() - val logLines = mutableListOf<LogcatLine>() - try { - val reader = BufferedReader(InputStreamReader(process.inputStream)) - var count = 0 - - while (count < lineCount) { - val line = reader.readLine() - if (line != null) { - val level = line.split(Regex("\\s+")).getOrNull(4) - val logLevel = - when (level?.uppercase()) { - "V" -> Log.VERBOSE - "D" -> Log.DEBUG - "I" -> Log.INFO - "W" -> Log.WARN - "E" -> Log.ERROR - else -> Log.VERBOSE - } - logLines.add(LogcatLine(logLevel, line)) - } else { - break + suspend fun getLogCatLines(): List<LogcatLine> = + withContext(WholphinDispatchers.IO) { + val lineCount = 500 + val args = + buildList { + add("logcat") + add("-d") + add("-t") + add(lineCount.toString()) + addAll(THIRD_PARTY_TAGS) + add("*:V") } - count++ + val process = ProcessBuilder().command(args).redirectErrorStream(true).start() + val logLines = mutableListOf<LogcatLine>() + try { + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var count = 0 + + while (count < lineCount) { + val line = reader.readLine() + if (line != null) { + val level = line.split(Regex("\\s+")).getOrNull(4) + val logLevel = + when (level?.uppercase()) { + "V" -> Log.VERBOSE + "D" -> Log.DEBUG + "I" -> Log.INFO + "W" -> Log.WARN + "E" -> Log.ERROR + else -> Log.VERBOSE + } + logLines.add(LogcatLine(logLevel, line)) + } else { + break + } + count++ + } + } finally { + process.destroy() } - } finally { - process.destroy() + return@withContext logLines } - return logLines - } - - fun ViewModel.sendAppLogs( - context: Context, - api: ApiClient, - clientInfo: ClientInfo?, - deviceInfo: DeviceInfo?, - ) { - viewModelScope.launchIO(ExceptionHandler(true)) { - val logcat = getLogCatLines().joinToString("\n") { it.text } - val body = - """ - Send App Logs - clientInfo=$clientInfo - deviceInfo=$deviceInfo - manufacturer=${Build.MANUFACTURER} - model=${Build.MODEL} - apiLevel=${Build.VERSION.SDK_INT} - - """.trimIndent() - Timber.w(body) - val response by api.clientLogApi.logFile(body + logcat) - showToast(context, "Sent! Filename=${response.fileName}") - } - } } } @@ -189,18 +259,144 @@ data class LogcatLine( val text: String, ) +data class DebugState( + val appInfo: List<String> = emptyList(), + val deviceInfo: List<String> = emptyList(), + val displayModes: List<String> = emptyList(), + val audioInfo: List<String> = emptyList(), +) + +val AudioDeviceInfo.details: String + get() { + val typeName = + when (type) { + AudioDeviceInfo.TYPE_HDMI -> "HDMI" + AudioDeviceInfo.TYPE_HDMI_ARC -> "ARC" + AudioDeviceInfo.TYPE_HDMI_EARC -> "eARC" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "Speaker Safe" + else -> "N/A" + } + val addressStr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + address + } else { + null + } + val encodings = + encodings.map { enc -> + when (enc) { + AudioFormat.ENCODING_INVALID -> "INVALID" + + AudioFormat.ENCODING_PCM_16BIT -> "PCM_16BIT" + + AudioFormat.ENCODING_PCM_8BIT -> "PCM_8BIT" + + AudioFormat.ENCODING_PCM_FLOAT -> "PCM_FLOAT" + + AudioFormat.ENCODING_AC3 -> "AC3" + + AudioFormat.ENCODING_E_AC3 -> "E_AC3" + + AudioFormat.ENCODING_DTS -> "DTS" + + AudioFormat.ENCODING_DTS_HD -> "DTS_HD" + + AudioFormat.ENCODING_MP3 -> "MP3" + + AudioFormat.ENCODING_AAC_LC -> "AAC_LC" + + AudioFormat.ENCODING_AAC_HE_V1 -> "AAC_HE_V1" + + AudioFormat.ENCODING_AAC_HE_V2 -> "AAC_HE_V2" + + AudioFormat.ENCODING_IEC61937 -> "IEC61937" + + AudioFormat.ENCODING_DOLBY_TRUEHD -> "DOLBY_TRUEHD" + + AudioFormat.ENCODING_AAC_ELD -> "AAC_ELD" + + AudioFormat.ENCODING_AAC_XHE -> "AAC_XHE" + + AudioFormat.ENCODING_AC4 -> "AC4" + + // AudioFormat.ENCODING_AC4_L4->"AC4_L4" + + AudioFormat.ENCODING_E_AC3_JOC -> "E_AC3_JOC" + + AudioFormat.ENCODING_DOLBY_MAT -> "DOLBY_MAT" + + AudioFormat.ENCODING_OPUS -> "OPUS" + + AudioFormat.ENCODING_PCM_24BIT_PACKED -> "PCM_24BIT_PACKED" + + AudioFormat.ENCODING_PCM_32BIT -> "PCM_32BIT" + + AudioFormat.ENCODING_MPEGH_BL_L3 -> "MPEGH_BL_L3" + + AudioFormat.ENCODING_MPEGH_BL_L4 -> "MPEGH_BL_L4" + + AudioFormat.ENCODING_MPEGH_LC_L3 -> "MPEGH_LC_L3" + + AudioFormat.ENCODING_MPEGH_LC_L4 -> "MPEGH_LC_L4" + + AudioFormat.ENCODING_DTS_UHD_P1 -> "DTS_UHD_P1" + + AudioFormat.ENCODING_DRA -> "DRA" + + AudioFormat.ENCODING_DTS_HD_MA -> "DTS_HD_MA" + + AudioFormat.ENCODING_DTS_UHD_P2 -> "DTS_UHD_P2" + + AudioFormat.ENCODING_DSD -> "DSD" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_AAC -> "IAMF_BASE_ENHANCED_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_FLAC -> "IAMF_BASE_ENHANCED_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_OPUS -> "IAMF_BASE_ENHANCED_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_PCM -> "IAMF_BASE_ENHANCED_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_AAC -> "IAMF_BASE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_FLAC -> "IAMF_BASE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_OPUS -> "IAMF_BASE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_PCM -> "IAMF_BASE_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_AAC -> "IAMF_SIMPLE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_FLAC -> "IAMF_SIMPLE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_OPUS -> "IAMF_SIMPLE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_PCM -> "IAMF_SIMPLE_PROFILE_PCM" + + else -> "invalid encoding $enc" + } + } + return "AudioDeviceInfo(id=$id, type=$type ($typeName), " + + "channelCounts=${channelCounts.contentToString()}, " + + "encodings=$encodings, " + + "productName=$productName, address=$addressStr)" + } + @Composable fun DebugPage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: DebugViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val scrollAmount = 100f val columnState = rememberLazyListState() val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsState() - fun scroll(reverse: Boolean = false) { + fun scroll( + reverse: Boolean = false, + scrollAmount: Float = 100f, + ) { scope.launch(ExceptionHandler()) { columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount) } @@ -209,10 +405,12 @@ fun DebugPage( val itemPlaybacks by viewModel.itemPlaybacks.collectAsState() val logcat by viewModel.logcat.collectAsState() + val padding = remember { PaddingValues(bottom = 32.dp) } + LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier .focusable() @@ -230,150 +428,94 @@ fun DebugPage( scroll(true) return@onKeyEvent true } + if (it.key == Key.MediaFastForward || it.key == Key.PageDown) { + scroll(false, 300f) + return@onKeyEvent true + } + if (it.key == Key.MediaSkipBackward || it.key == Key.PageUp) { + scroll(true, 300f) + return@onKeyEvent true + } return@onKeyEvent false }, ) { item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "App Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - val buildTime = Date(BuildConfig.BUILD_TIME) - val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) - val installInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val installSource = - context.packageManager.getInstallSourceInfo(context.packageName) - buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - add("Install source: ${installSource.packageSource}") - } - add("Installer: ${installSource.installingPackageName}") - add("Initiator: ${installSource.initiatingPackageName}") - } - } else { - listOf(context.packageManager.getInstallerPackageName(context.packageName)) - } - ( - listOf( - "Version Name: ${pkgInfo.versionName}", - "Version Code: ${pkgInfo.versionCodeLong}", - "ClientInfo: ${viewModel.clientInfo}", - "Build type: ${BuildConfig.BUILD_TYPE}", - "Build flavor: ${BuildConfig.FLAVOR}", - "Build time: $buildTime", - "FFMPEG included: ${viewModel.ffmpegIncluded}", - "AV1 included: ${viewModel.av1Included}", - "libmpv loaded: ${viewModel.libMpvLoaded}", - "Debug enabled: ${BuildConfig.DEBUG}", - "ABIs: ${Build.SUPPORTED_ABIS.toList()}", - ) + installInfo - ).forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("App Information") + state.appInfo.forEach { + BodyText(it) } } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "Device Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("Device Information") + state.deviceInfo.forEach { + BodyText(it) + } - listOf( - "DeviceInfo: ${viewModel.deviceInfo}", - "Manufacturer: ${Build.MANUFACTURER}", - "Model: ${Build.MODEL}", - "API Level: ${Build.VERSION.SDK_INT}", - "Display Modes:", - *viewModel.supportedModes, - ).forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SubSectionTitle("Display Modes") + state.displayModes.forEach { + BodyText(it) + } + + SubSectionTitle("Audio Devices") + state.audioInfo.forEach { + BodyText(it) } } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "AppPreferences", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = preferences.appPreferences.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("AppPreferences") + BodyText(preferences.appPreferences.toString()) } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "User Information", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Current server: ${viewModel.serverRepository.currentServer}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Current user: ${viewModel.serverRepository.currentUser}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("User Information") + BodyText("Current server: ${viewModel.serverRepository.currentServer}") + BodyText("Current user: ${viewModel.serverRepository.currentUser}") + BodyText("User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}") } } item { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(padding), ) { - Text( - text = "Database", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "ItemPlayback", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + SectionTitle("Database") + + SubSectionTitle("ItemPlayback") itemPlaybacks.forEach { - Text( - text = it.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) + BodyText(it.toString()) } } } @@ -382,32 +524,55 @@ fun DebugPage( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = "Logcat", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - logcat.forEach { (level, line) -> - val color = - when (level) { - Log.VERBOSE -> MaterialTheme.colorScheme.onSurface - Log.DEBUG -> Color(0xff2bc4cf) - Log.INFO -> Color(0xff2bcf8b) - Log.WARN -> Color(0xffdde663) - Log.ERROR -> Color(0xffe67063) - else -> MaterialTheme.colorScheme.onSurface - } - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - color = color, - ) - } + SectionTitle("Logcat") } } + items(logcat) { (level, line) -> + val color = + when (level) { + Log.VERBOSE -> MaterialTheme.colorScheme.onSurface + Log.DEBUG -> Color(0xff2bc4cf) + Log.INFO -> Color(0xff2bcf8b) + Log.WARN -> Color(0xffdde663) + Log.ERROR -> Color(0xffe67063) + else -> MaterialTheme.colorScheme.onSurface + } + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = color, + ) + } } } +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun SubSectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun BodyText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) +} + private val THIRD_PARTY_TAGS = listOf( "libc:F", 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 deleted file mode 100644 index 73ad4a2e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import android.content.res.Resources -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.ui.graphics.Color -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.util.supportedPlayableTypes -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -data class MoreDialogActions( - val navigateTo: (Destination) -> Unit, - val onClickWatch: (UUID, Boolean) -> Unit, - val onClickFavorite: (UUID, Boolean) -> Unit, - val onClickAddPlaylist: (UUID) -> Unit, - val onSendMediaInfo: (UUID) -> Unit, - val onClickDelete: (BaseItem) -> Unit, - val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, - val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, - val onClickAddToQueue: (BaseItem) -> Unit = {}, -) - -fun buildMoreDialogItemsForHome( - resources: Resources, - item: BaseItem, - seriesId: UUID?, - playbackPosition: Duration, - watched: Boolean, - favorite: Boolean, - canDelete: Boolean, - actions: MoreDialogActions, - canRemoveContinueWatching: Boolean = false, - canRemoveNextUp: Boolean = false, -): List<DialogItem> = - buildList { - val itemId = item.id - add( - DialogItem( - resources.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - actions.onClickGoTo(item) - }, - ) - if (item.type in supportedPlayableTypes) { - if (playbackPosition >= 1.seconds) { - add( - DialogItem( - resources.getString(R.string.resume), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - playbackPosition.inWholeMilliseconds, - ), - ) - }, - ) - add( - DialogItem( - resources.getString(R.string.restart), - Icons.Default.Refresh, -// iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - 0L, - ), - ) - }, - ) - } else { - add( - DialogItem( - resources.getString(R.string.play), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - itemId, - 0L, - ), - ) - }, - ) - } - } - if (item.type == BaseItemKind.MUSIC_ALBUM) { - add( - DialogItem( - resources.getString(R.string.add_to_queue), - Icons.Default.Add, - ) { - actions.onClickAddToQueue(item) - }, - ) - } - add( - DialogItem( - text = R.string.add_to_playlist, - iconStringRes = R.string.fa_list_ul, - ) { - actions.onClickAddPlaylist.invoke(itemId) - }, - ) - if (canDelete) { - add( - DialogItem( - resources.getString(R.string.delete), - Icons.Default.Delete, - iconColor = Color.Red.copy(alpha = .8f), - ) { - actions.onClickDelete.invoke(item) - }, - ) - } - if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) { - add( - DialogItem( - text = R.string.remove_continue_watching, - iconStringRes = R.string.fa_eye, - ) { - actions.onClickWatch.invoke(itemId, false) - }, - ) - } - if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { - add( - DialogItem( - text = R.string.remove_next_up, - iconStringRes = R.string.fa_tag, - ) { - actions.onClickRemoveFromNextUp.invoke(item) - }, - ) - } - add( - DialogItem( - text = if (watched) R.string.mark_unwatched else R.string.mark_watched, - iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, - ) { - actions.onClickWatch.invoke(itemId, !watched) - }, - ) - add( - DialogItem( - text = if (favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (favorite) Color.Red else Color.Unspecified, - ) { - actions.onClickFavorite.invoke(itemId, !favorite) - }, - ) - seriesId?.let { - add( - DialogItem( - resources.getString(R.string.go_to_series), - Icons.AutoMirrored.Filled.ArrowForward, - ) { - actions.navigateTo( - Destination.MediaItem( - it, - BaseItemKind.SERIES, - null, - ), - ) - }, - ) - } - add( - DialogItem( - text = R.string.send_media_info_log_to_server, - iconStringRes = R.string.fa_file_video, - ) { - actions.onSendMediaInfo.invoke(itemId) - }, - ) - } 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 b0124313..0e46999f 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,324 +1,129 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -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 +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions -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.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderView +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GridClickActions -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare -import com.github.damontecres.wholphin.ui.components.ViewOptionsWide -import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions -import com.github.damontecres.wholphin.ui.data.MovieSortOptions -import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.components.KeyedTabbedPage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.defaultViewOptions +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.data.rememberSortOptions import com.github.damontecres.wholphin.ui.tryRequestFocus -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder @Composable fun FavoritesPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: FavoritesViewModel = hiltViewModel(), ) { - val uiPrefs = preferences.appPreferences.interfacePreferences - val rememberedTabIndex = - remember { - preferencesViewModel.getRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - 0, - ) + OneTimeLaunchedEffect { viewModel.init() } + val state by viewModel.state.collectAsState() + + var showTabs by rememberSaveable { mutableStateOf(true) } + + when (val s = state.loadingState) { + is FavoritesLoadingState.Error -> { + ErrorMessage(s.message, s.exception, modifier) } - val tabs = - listOf( - stringResource(R.string.movies), - stringResource(R.string.tv_shows), - stringResource(R.string.episodes), - stringResource(R.string.videos), - stringResource(R.string.playlists), - stringResource(R.string.people), - ) - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } + FavoritesLoadingState.Loading, + FavoritesLoadingState.Pending, + -> { + LoadingPage(modifier) + } - LaunchedEffect(selectedTabIndex) { - logTab("favorites", selectedTabIndex) - preferencesViewModel.saveRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - selectedTabIndex, - ) - preferencesViewModel.backdropService.clearBackdrop() - } - var showHeader by rememberSaveable { mutableStateOf(true) } - - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, + FavoritesLoadingState.NoFavorites -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Box( + contentAlignment = Alignment.Center, modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) + modifier + .focusRequester(focusRequester) + .focusable(), + ) { + Text( + text = stringResource(R.string.no_favorites_found), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier, + ) + } } - val actions = - remember { - GridClickActions( - onClickItem = { _, item -> onClickItem.invoke(item) }, - ) - } - // TODO playEnabled = true for movies & episodes - when (selectedTabIndex) { - // Movies - 0 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_movies", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), - ), - ), - showTitle = false, - recursive = true, - sortOptions = MovieSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } + FavoritesLoadingState.Success -> { +// LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + KeyedTabbedPage( + selectedTabKey = state.tabKey, + updateSelectedTabKey = viewModel::updateSelectedTabKey, + tabs = state.tabs, + showTabs = showTabs, + isShowClock = state.isShowClock, + modifier = modifier, + ) { type, tabDetails -> + val collectionState = state.favorites[type] + if (collectionState != null) { + val provider = remember(type) { viewModel.createTypedProvider(type) } + val contextMenu = rememberContextMenu(preferences, provider) + val actions = + remember(type) { + GridClickActions( + onClickItem = { _, item -> + viewModel.navigationManager.navigateTo( + item.destination(), + ) + }, + onLongClickItem = contextMenu::showContextMenu, + ) + } + CollectionFolderViewContent( + preferences = preferences, + state = collectionState, + savedPosition = 0, + itemId = remember { viewModel.libraryDisplayItemId(type) }, + initialFilter = CollectionFolderFilter(), + recursive = true, + actions = actions, + sortOptions = rememberSortOptions(type), + // TODO playEnabled = true for movies & episodes + playEnabled = false, + defaultViewOptions = type.defaultViewOptions, + viewActions = provider, + provider = provider, + showTitle = false, + positionCallback = { columns, index -> + showTabs = index < columns + }, + focusRequesterOnEmpty = null, + filterOptions = DefaultForFavoritesFilterOptions, + ) - // TV - 1 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_series", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.SERIES), - ), - ), - showTitle = false, - recursive = true, - sortOptions = SeriesSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // Episodes - 2 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_episodes", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), - ), - ), - showTitle = false, - recursive = true, - sortOptions = EpisodeSortOptions, - defaultViewOptions = ViewOptionsWide, - useSeriesForPrimary = false, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // Videos - 3 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_videos", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.VIDEO), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsWide, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // Playlists - 4 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_playlists", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.PLAYLIST), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsSquare, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - // People - 5 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = "${NavDrawerItem.Favorites.id}_people", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - favorite = true, - override = GetItemsFilterOverride.PERSON, - ), - ), - initialSortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - showTitle = false, - recursive = true, - sortOptions = listOf(), - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - filterOptions = listOf(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } - - else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + contextMenu.Compose() + } else { + ErrorMessage("Invalid tab $type", null) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt new file mode 100644 index 00000000..adbd9a44 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -0,0 +1,798 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.RememberedTabService +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.StreamChoiceService +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.collectLatestIn +import com.github.damontecres.wholphin.ui.components.CollectionFolderState +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewActions +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.components.defaultViewOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +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.showToast +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import timber.log.Timber +import java.util.SortedMap +import java.util.TreeMap +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val navigationManager: NavigationManager, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, + val streamChoiceService: StreamChoiceService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, + private val rememberedTabService: RememberedTabService, + ) : ViewModel() { + private val _state = MutableStateFlow(FavoritesPageState()) + val state: StateFlow<FavoritesPageState> = _state + + fun init() { + viewModelScope.launchIO { + val rememberTabs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + + setupPossibleTypes() + + val favoriteTypeOptions = state.value.favoriteTypeOptions + if (favoriteTypeOptions.isNotEmpty()) { + val tabKey = + if (rememberTabs) { + val rememberedTab = + rememberedTabService + .getRememberedTab(NavDrawerItem.Favorites.id) + ?.let { + BaseItemKind.entries[it] + } + if (rememberedTab != null && rememberedTab in favoriteTypeOptions) { + rememberedTab + } else { + favoriteTypeOptions.first() + } + } else { + favoriteTypeOptions.first() + } + val jobs = + favoriteTypeOptions.associateWith { type -> initialFetchForType(type) } + initialFetchAndWait(tabKey, jobs) + } else { + Timber.w("No available types to check for favorites!") + _state.update { + it.copy(loadingState = FavoritesLoadingState.NoFavorites) + } + } + + userPreferencesService.flow + .map { it.appPreferences.interfacePreferences.showClock } + .collectLatestIn(viewModelScope) { isShowClock -> + _state.update { it.copy(isShowClock = isShowClock) } + } + } + } + + @VisibleForTesting + internal fun setupPossibleTypes() { + val availableTypes = + navDrawerService.state.value.allLibraries + .possibleFavoriteTypes(false) + .toSet() + val favoriteTypeOptions = favoriteOptions.filter { it in availableTypes } + _state.update { + it.copy( + favoriteTypeOptions = favoriteTypeOptions, + ) + } + } + + /** + * Starts queries for favorites, waiting for [firstTabKey] to complete + * + * Exposed for testing + */ + internal suspend fun initialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>, + ) { + val completed = + jobs[firstTabKey]?.let { + when (val result = it.await()) { + DataLoadingState.Loading, + DataLoadingState.Pending, + -> false + + is DataLoadingState.Error, + -> true + + is DataLoadingState.Success<List<BaseItem?>> -> result.data.isNotEmpty() + } + } == true + if (completed) { + Timber.v("Got completed for %s", firstTabKey) + _state.update { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstTabKey, + ) + } + } else { + // If the first key wasn't successful, wait in order until first successful & non-empty + val favoriteTypeOptions = state.value.favoriteTypeOptions + val firstCompleted = + favoriteTypeOptions.firstOrNull { type -> + val job = jobs[type] ?: return@firstOrNull false + val result = job.await() + result is DataLoadingState.Error || + (result is DataLoadingState.Success<List<BaseItem?>> && result.data.isNotEmpty()) + } + _state.update { + if (firstCompleted != null) { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstCompleted, + ) + } else { + // None were error or successful & non-empty + // Check if they were all empty which means the user has no favorites at all + val allEmpty = + jobs.all { (_, job) -> + val result = job.await() + result is DataLoadingState.Success<List<BaseItem?>> && result.data.isEmpty() + } + if (allEmpty) { + it.copy( + loadingState = FavoritesLoadingState.NoFavorites, + ) + } else { + // This means a least one failed + val firstError = + jobs.entries.firstOrNull { (_, job) -> + job.await() is DataLoadingState.Error + } + if (firstError != null) { + it.copy( + loadingState = FavoritesLoadingState.Success, + tabKey = firstError.key, + ) + } else { + // This shouldn't happen + it.copy( + loadingState = FavoritesLoadingState.Error("An unknown error occurred"), + ) + } + } + } + } + } + } + + /** + * Do the initial fetch for a [BaseItemKind] which will look up any existing saved sort and filter + */ + private fun initialFetchForType(type: BaseItemKind): Deferred<DataLoadingState<List<BaseItem?>>> = + viewModelScope.async(WholphinDispatchers.IO) { + try { + val collectionState = + CollectionFolderState( + item = DataLoadingState.Loading, + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + viewOptions = ViewOptions(), + ) + _state.value.favorites[type] = collectionState + + val libraryDisplayInfo = + serverRepository.currentUser?.let { user -> + libraryDisplayInfoDao.getItem(user, libraryDisplayItemId(type)) + } + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection.DEFAULT + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter(favorite = true) + val viewOptions = libraryDisplayInfo?.viewOptions ?: type.defaultViewOptions + fetchType(type, sortAndDirection, filter, viewOptions) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching favorites for %s", type) + updateForException(type, ex) + DataLoadingState.Error(ex) + } + } + + /** + * Query for favorites of the specified [BaseItemKind] using the specified sort and filter + */ + @VisibleForTesting + internal suspend fun fetchType( + type: BaseItemKind, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, + viewOptions: ViewOptions, + ): DataLoadingState<List<BaseItem?>> { + val collectionState = + CollectionFolderState( + item = DataLoadingState.Loading, + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + viewOptions = ViewOptions(), + ) + _state.value.favorites[type] = collectionState + try { + val pager = createPager(type, filter, sortAndDirection) + _state.value.favorites[type] = + collectionState.copy( + item = DataLoadingState.Success(null), + items = DataLoadingState.Success(pager), + backgroundLoading = LoadingState.Success, + sortAndDirection = sortAndDirection, + filter = filter, + viewOptions = viewOptions, + ) + Timber.v("Got %s favorites for %s", pager.size, type) + _state.update { + val newTabs = + it.createMap { + if (pager.isNotEmpty()) { + put(type, TabDetails(formatTypeName(type))) + } else { + remove(type) + } + } + it.copy( + tabs = newTabs, + ) + } + return DataLoadingState.Success(pager) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + updateForException(type, ex) + return DataLoadingState.Error(ex) + } + } + + /** + * If fetching data for a type fails, this should be called to update the state with the error + */ + private fun updateForException( + type: BaseItemKind, + ex: Exception, + ) { + _state.update { + // Want to store the error for the tab + _state.value.favorites[type] = + CollectionFolderState( + item = DataLoadingState.Success(null), + items = DataLoadingState.Error(ex), + backgroundLoading = LoadingState.Success, + viewOptions = type.defaultViewOptions, + ) + // And make sure the tab is included + val newTabs = + it.createMap { + put(type, TabDetails(formatTypeName(type))) + } + it.copy(tabs = newTabs) + } + } + + private fun createGetItemsRequest( + type: BaseItemKind, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): GetItemsRequest = + filter.applyTo( + GetItemsRequest( + isFavorite = true, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + overwriteIncludeTypes = false, + ) + + private fun createGetArtistsRequest( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): GetArtistsRequest = + filter.applyTo( + GetArtistsRequest( + isFavorite = true, + fields = SlimItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + + private fun createGetPersonsRequest(filter: GetItemsFilter): GetPersonsRequest = + filter.applyTo( + GetPersonsRequest( + isFavorite = true, + fields = SlimItemFields, + ), + ) + + private suspend fun createPager( + type: BaseItemKind, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ): ApiRequestPager<*> = + when (type) { + BaseItemKind.MUSIC_ARTIST -> { + val request = createGetArtistsRequest(filter, sortAndDirection) + ApiRequestPager(api, request, GetArtistsHandler, viewModelScope, pageSize = 50) + } + + BaseItemKind.PERSON -> { + val request = createGetPersonsRequest(filter) + ApiRequestPager(api, request, GetPersonsHandler, viewModelScope, pageSize = 50) + } + + else -> { + val request = createGetItemsRequest(type, filter, sortAndDirection) + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope, pageSize = 50) + } + }.init() + + fun libraryDisplayItemId(type: BaseItemKind): String { + // Previous version used hardcoded keys before more types were supported + // So override those type names so that user's settings are maintained + val typeKey = + when (type) { + BaseItemKind.MOVIE -> "movies" + BaseItemKind.SERIES -> "series" + BaseItemKind.EPISODE -> "episodes" + BaseItemKind.VIDEO -> "videos" + BaseItemKind.PLAYLIST -> "playlists" + BaseItemKind.PERSON -> "people" + else -> type.serialName + } + return "${NavDrawerItem.Favorites.id}_$typeKey" + } + + private fun collectionStateFor(type: BaseItemKind): CollectionFolderState? = state.value.favorites[type] + + fun updateSelectedTabKey(newKey: BaseItemKind) { + viewModelScope.launchDefault { backdropService.clearBackdrop() } + viewModelScope.launchIO { + val rememberTabs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.rememberSelectedTab + if (rememberTabs) { + rememberedTabService.saveRememberedTab( + NavDrawerItem.Favorites.id, + newKey.ordinal, + ) + } + } + _state.update { it.copy(tabKey = newKey) } + } + + private suspend fun refreshAfterMutate( + type: BaseItemKind, + position: Int, + itemId: UUID, + ) { + try { + val pager = + collectionStateFor(type)?.let { + ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + } + pager?.refreshItem(position, itemId) + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after mutate %s", itemId) + showToast(context, "Error refreshing after item mutate") + } + } + + private suspend fun refreshAfterDelete( + type: BaseItemKind, + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + collectionStateFor(type)?.let { + ((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + } + + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.id) + 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", deletedItem.id) + showToast(context, "Error refreshing after item deleted") + } + } + + private fun saveLibraryDisplayInfo( + type: BaseItemKind, + newFilter: GetItemsFilter, + newSort: SortAndDirection, + viewOptions: ViewOptions?, + ) { + serverRepository.currentUser?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = libraryDisplayItemId(type), + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + } + + private fun saveViewOptions( + type: BaseItemKind, + viewOptions: ViewOptions, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = collectionState.filter, + newSort = collectionState.sortAndDirection, + viewOptions = viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + viewOptions = viewOptions, + ) + if (!viewOptions.showBackdrop) { + backdropService.clearBackdrop() + } + } + } + } + + fun createTypedProvider(type: BaseItemKind) = TypedProvider(type) + + /** + * This provides context menu actions and other actions on a per [BaseItemKind] basis + */ + @Immutable + inner class TypedProvider( + val type: BaseItemKind, + ) : ContextMenuProvider, + CollectionFolderViewActions { + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun navigateTo(destination: Destination) = navigationManager.navigateTo(destination) + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(type, index, item) + } + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshAfterMutate(type, position, itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, favorite) + collectionStateFor(type)?.let { collectionState -> + // TODO use pager.refreshPagesAfter ?? + fetchType( + type, + collectionState.sortAndDirection, + collectionState.filter, + collectionState.viewOptions, + ) + } + } + } + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + override fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + override fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + Timber.v("onSortChange: type=%s, sortAndDirection=%s", type, sortAndDirection) + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = filter, + newSort = sortAndDirection, + viewOptions = collectionState.viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + filter = filter, + sortAndDirection = sortAndDirection, + ) + fetchType(type, sortAndDirection, filter, collectionState.viewOptions) + } + } + } + + override fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + Timber.v("onFilterChange: type=%s, newFilter=%s", type, newFilter) + collectionStateFor(type)?.let { collectionState -> + saveLibraryDisplayInfo( + type = type, + newFilter = newFilter, + newSort = collectionState.sortAndDirection, + viewOptions = collectionState.viewOptions, + ) + state.value.favorites[type] = + collectionState.copy( + filter = newFilter, + ) + fetchType( + type, + collectionState.sortAndDirection, + newFilter, + collectionState.viewOptions, + ) + } + } + } + + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + // TODO use includeItemTypes? + filterOptionCache.getFilterOptionValues( + null, + filterOption, + ) + + override suspend fun positionOfLetter(letter: Char): Int? = + collectionStateFor(type)?.let { collectionState -> + when (type) { + BaseItemKind.MUSIC_ARTIST -> { + val request = + createGetArtistsRequest( + collectionState.filter, + collectionState.sortAndDirection, + ).copy( + enableUserData = false, + limit = 0, + enableTotalRecordCount = true, + ) + GetArtistsHandler.execute(api, request).content.totalRecordCount + } + + BaseItemKind.PERSON -> { + null + } + + else -> { + val request = + createGetItemsRequest( + type, + collectionState.filter, + collectionState.sortAndDirection, + ).copy( + enableUserData = false, + limit = 0, + enableTotalRecordCount = true, + ) + GetItemsRequestHandler.execute(api, request).content.totalRecordCount + } + } + } + + override fun saveViewOptions(viewOptions: ViewOptions) { + saveViewOptions(type, viewOptions) + } + + override fun onClickRandom() { + viewModelScope.launchIO { + try { + collectionStateFor(type)?.let { collectionState -> + val random = + (collectionState.items.successValue as? BlockingList<BaseItem?>)?.randomBlocking() + Timber.d("Got random item: %s", random?.id) + random?.destination()?.let { + navigateTo(random.destination()) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting random") + showToast(context, "Error: ${ex.localizedMessage}}") + } + } + } + } + } + +data class FavoritesPageState( + val loadingState: FavoritesLoadingState = FavoritesLoadingState.Pending, + val favoriteTypeOptions: List<BaseItemKind> = emptyList(), + val favorites: SnapshotStateMap<BaseItemKind, CollectionFolderState> = SnapshotStateMap(), + val tabs: SortedMap<BaseItemKind, TabDetails> = TreeMap(compareBy { favoriteOptions.indexOf(it) }), + val tabKey: BaseItemKind = favoriteOptions.first(), + val isShowClock: Boolean = true, +) + +sealed interface FavoritesLoadingState { + data object Pending : FavoritesLoadingState + + data object Loading : FavoritesLoadingState + + data object Success : FavoritesLoadingState + + data object NoFavorites : FavoritesLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : FavoritesLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} + +/** + * List of supported favorite types + * + * This is also the order they will appear in the UI + */ +val favoriteOptions = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +/** + * Creates a new tab map from the current state applying the supplied block which can contain mutations + */ +private inline fun FavoritesPageState.createMap(block: TreeMap<BaseItemKind, TabDetails>.() -> Unit) = + TreeMap<BaseItemKind, TabDetails>(compareBy { favoriteOptions.indexOf(it) }) + .apply { + putAll(this@createMap.tabs) + block.invoke(this) + } + +/** + * Given the list of libraries, return the possible favorite types + */ +fun List<Library>.possibleFavoriteTypes(sort: Boolean) = + flatMap { it.collectionType.baseItemKinds } + .distinct() + .filter { it in favoriteOptions } + .let { + // No library for persons, so always include it + val list = it + listOf(BaseItemKind.PERSON) + if (sort) { + list.sortedWith(compareBy { favoriteOptions.indexOf(it) }) + } else { + list + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index e2288281..1fd7ea9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -10,17 +10,13 @@ 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.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.HomeRowConfig @@ -30,50 +26,44 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MusicService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService 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.services.tvAccess import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.components.ContextMenu -import com.github.damontecres.wholphin.ui.components.ContextMenuActions -import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.StudioCardGrid -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.components.rememberContextMenu 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.playback.scale -import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber import java.util.UUID @@ -93,10 +83,11 @@ class HomeRowGridViewModel private val mediaManagementService: MediaManagementService, private val musicService: MusicService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, @Assisted private val title: StringProvider, @Assisted private val rowConfig: HomeRowConfig, - ) : ViewModel() { + ) : ViewModel(), + ContextMenuProvider { @AssistedFactory interface Factory { fun create( @@ -170,44 +161,52 @@ class HomeRowGridViewModel } } - fun setWatched( + override fun setWatched( position: Int, itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (state.value.loading as? HomeRowLoadingState.Success)?.let { - (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } - fun setFavorite( + override fun setFavorite( position: Int, itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - (state.value.loading as? HomeRowLoadingState.Success)?.let { - (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } } + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) } } - fun navigateTo(destination: Destination) { + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } - fun canDelete( + override fun canDelete( item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) - fun deleteItem( + override fun deleteItem( index: Int, item: BaseItem, ) { @@ -233,47 +232,12 @@ fun HomeRowGrid( hiltViewModel<HomeRowGridViewModel, HomeRowGridViewModel.Factory>( creationCallback = { it.create(destination.title, destination.config) }, ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - var position by rememberInt(destination.initialPosition) + val contextMenu = rememberContextMenu(preferences, viewModel) val gridFocusRequester = remember { FocusRequester() } val viewOptions = destination.config.viewOptions - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onDeleteItem = { viewModel.deleteItem(position, it) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) - } - Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -299,6 +263,7 @@ fun HomeRowGrid( includeItemTypes = null, modifier = Modifier.fillMaxSize(), initialPosition = destination.initialPosition, + collectionType = CollectionType.UNKNOWN, ) } @@ -318,32 +283,11 @@ fun HomeRowGrid( viewModel.navigateTo(item.destination(index)) } } - val onLongClickItem = - remember { - { index: Int, item: BaseItem -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), - canRemoveContinueWatching = false, - canRemoveNextUp = false, // TODO - actions = contextActions, - ) - } - } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( pager = st.items, onClickItem = onClickItem, - onLongClickItem = onLongClickItem, + onLongClickItem = contextMenu::showContextMenu, onClickPlay = { _, _ -> }, letterPosition = { -1 }, gridFocusRequester = gridFocusRequester, @@ -352,7 +296,6 @@ fun HomeRowGrid( modifier = Modifier.fillMaxSize(), initialPosition = destination.initialPosition, positionCallback = { columns, newPosition -> - position = newPosition }, cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( @@ -376,39 +319,5 @@ fun HomeRowGrid( } } } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - viewModel.serverRepository.currentUserDto - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = playlistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - playlistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - elevation = 3.dp, - ) - } + contextMenu.Compose() } 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 0efb9ae8..27212b5e 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -45,10 +46,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton @@ -155,6 +157,7 @@ class PersonViewModel _state.update { it.copy( discovered = results, + discoverPerson = seerrService.discoverPerson(person), ) } } @@ -179,7 +182,7 @@ class PersonViewModel GetItemsRequest( personIds = listOf(itemId), includeItemTypes = listOf(type), - fields = SlimItemFields, + fields = ItemRowFields, recursive = true, sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME), sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING), @@ -219,6 +222,7 @@ data class PersonState( val series: RowLoadingState = RowLoadingState.Pending, val episodes: RowLoadingState = RowLoadingState.Pending, val discovered: List<DiscoverItem> = emptyList(), + val discoverPerson: DiscoverItem? = null, ) @Composable @@ -255,6 +259,12 @@ fun PersonPage( imageType = ImageType.PRIMARY, ) } + // A long filmography is hard to navigate as a row, so it is cut at the same + // limit as the home page rows and the rest is left to the full grid + val maxItems = preferences.appPreferences.homePagePreferences.maxItemsPerRow + val discovered = + remember(state.discovered, maxItems) { state.discovered.take(maxItems) } + PersonPageContent( preferences = preferences, name = name, @@ -274,10 +284,18 @@ fun PersonPage( favoriteOnClick = { viewModel.setFavorite(!person.favorite) }, - discovered = state.discovered, + discovered = discovered, onClickDiscover = { index, item -> viewModel.navigationManager.navigateTo(item.destination) }, + onClickViewMoreDiscover = { + state.discoverPerson?.let { + viewModel.navigationManager.navigateTo( + Destination.DiscoveredItem(it, maxItems), + ) + } + }, + enableViewMoreDiscover = state.discovered.size > discovered.size, modifier = modifier, ) AnimatedVisibility(showOverviewDialog) { @@ -321,6 +339,8 @@ fun PersonPageContent( overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + onClickViewMoreDiscover: () -> Unit, + enableViewMoreDiscover: Boolean, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -363,7 +383,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.movies), movies), + title = rowTitle(stringResource(R.string.movies_title), movies), state = movies, rowIndex = MOVIE_ROW, position = position, @@ -376,7 +396,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.tv_shows), series), + title = rowTitle(stringResource(R.string.tv_shows_title), series), state = series, rowIndex = SERIES_ROW, position = position, @@ -408,6 +428,7 @@ fun PersonPageContent( }, onLongClick = onLongClick, imageHeight = Cards.heightEpisode, + aspectRatio = item?.aspectRatio ?: AspectRatios.FOUR_THREE, modifier = mod .ifElse( @@ -434,6 +455,11 @@ fun PersonPageContent( onLongClickItem = { _, _ -> }, onCardFocus = {}, focusRequester = focusRequester, + enableViewMore = enableViewMoreDiscover, + onClickViewMore = { + position = RowColumn(DISCOVER_ROW, discovered.size) + onClickViewMoreDiscover.invoke() + }, ) } } @@ -503,8 +529,9 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.born) + ": ${formatDate(it)} (${ - stringResource( - R.string.years_old, + pluralStringResource( + R.plurals.years_old, + age, age, ) })" @@ -533,8 +560,9 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.died) + ": ${formatDate(it)} (${ - stringResource( - R.string.years_old, + pluralStringResource( + R.plurals.years_old, + age, age, ) })" 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 7a78701a..b7085db6 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 @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -15,9 +14,11 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -35,6 +36,7 @@ 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.focusProperties import androidx.compose.ui.focus.focusRequester @@ -42,6 +44,8 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -64,14 +68,18 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo 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.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -79,6 +87,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.FilterByButton +import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.MusicContextActions import com.github.damontecres.wholphin.ui.components.Optional @@ -93,32 +102,39 @@ import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.roundSeconds +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.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.MediaType import org.jellyfin.sdk.model.api.SortOrder @@ -141,7 +157,9 @@ class PlaylistViewModel private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, - private val mediaReportService: MediaReportService, + private val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, + private val playlistCreator: PlaylistCreator, @Assisted itemId: UUID, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory @@ -165,9 +183,47 @@ class PlaylistViewModel .getItem(itemId) .content .let { BaseItem(it, false) } - state.update { it.copy(playlist = playlist) } + val user = serverRepository.currentUser + val canEdit = + user?.let { user -> + try { + val permission by api.playlistsApi.getPlaylistUser(itemId, user.id) + permission.canEdit + } catch (ex: CancellationException) { + throw ex + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + // Server will return this if no permission exists + Timber.w( + "User doesn't have permission to edit playlist %s", + itemId, + ) + } else { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + } + false + } catch (ex: Exception) { + Timber.e( + ex, + "Error checking user permission for playlist %s", + itemId, + ) + false + } + } ?: false + state.update { + it.copy( + playlist = playlist, + canEdit = canEdit, + ) + } + val libraryDisplayInfo = - serverRepository.currentUser?.let { user -> + user?.let { user -> libraryDisplayInfoDao.getItem(user, itemId) } val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() @@ -221,7 +277,7 @@ class PlaylistViewModel GetItemsRequest( parentId = itemId, userId = user.id, - fields = DefaultItemFields, + fields = SlimItemFields, sortBy = listOf(sortAndDirection.sort), sortOrder = listOf(sortAndDirection.direction), ), @@ -301,9 +357,7 @@ class PlaylistViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) @@ -317,19 +371,60 @@ class PlaylistViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) } fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) } fun sendMediaReport(itemId: UUID) { - viewModelScope.launchDefault { mediaReportService.sendReportFor(itemId) } + viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } + } + + fun removeFromPlaylist( + index: Int, + itemId: UUID, + ) { + viewModelScope.launchIO { + try { + playlistCreator.removeFromServerPlaylist( + playlistId = this@PlaylistViewModel.itemId, + itemId = itemId, + ) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e( + ex, + "Error removing %s from playlist %s", + itemId, + this@PlaylistViewModel.itemId, + ) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onMoveItem( + index: Int, + itemId: UUID, + direction: MoveDirection, + ) { + viewModelScope.launchIO { + val newIndex = index + if (direction == MoveDirection.UP) -1 else 1 + api.playlistsApi.moveItem( + playlistId = this@PlaylistViewModel.itemId.toServerString(), + itemId = itemId.toServerString(), + newIndex = newIndex, + ) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1) + } } } @@ -353,6 +448,7 @@ data class PlaylistDetailsState( ), ), val loading: LoadingState = LoadingState.Pending, + val canEdit: Boolean = false, ) @Composable @@ -366,7 +462,6 @@ fun PlaylistDetails( ), addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current val state by viewModel.state.collectAsState() val musicState by viewModel.musicState.collectAsState() @@ -404,19 +499,22 @@ fun PlaylistDetails( } } val musicContextActions = - MusicContextActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, - onClickPlayNext = { index, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - ) + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + } val contextActions = remember { ContextMenuActions( @@ -424,7 +522,7 @@ fun PlaylistDetails( onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO) + addToPlaylistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel::sendMediaReport, @@ -435,6 +533,7 @@ fun PlaylistDetails( onChooseTracks = {}, onClearChosenStreams = {}, onClickRemoveFromNextUp = {}, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, ) } @@ -452,21 +551,23 @@ fun PlaylistDetails( play(0, it, shuffle) } }, - onLongClickIndex = { index, item -> + onShowContextMenu = { index, item, fromLongClick -> showContextMenu = if (item.type == BaseItemKind.AUDIO) { ContextMenu.ForMusic( - fromLongClick = true, + fromLongClick = fromLongClick, item = item, index = index, canDelete = viewModel.canDelete(item, preferences.appPreferences), canRemoveFromQueue = false, actions = musicContextActions, + showRemoveFromPlaylist = state.canEdit, ) } else { ContextMenu.ForBaseItem( - fromLongClick = true, + fromLongClick = fromLongClick, item = item, + index = index, chosenStreams = null, showGoTo = true, showStreamChoices = false, @@ -474,12 +575,15 @@ fun PlaylistDetails( canRemoveContinueWatching = false, canRemoveNextUp = false, actions = contextActions, + showRemoveFromPlaylist = state.canEdit, ) } }, filterAndSort = state.filterAndSort, onFilterAndSortChange = viewModel::loadItems, getPossibleFilterValues = viewModel::getFilterOptionValues, + canEdit = state.canEdit, + onMoveItem = viewModel::onMoveItem, modifier = modifier, ) showContextMenu?.let { contextMenu -> @@ -510,6 +614,7 @@ fun PlaylistDetails( addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = addToPlaylistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -521,18 +626,19 @@ fun PlaylistDetailsContent( items: List<BaseItem?>, musicState: MusicServiceState, onClickIndex: (Int, BaseItem) -> Unit, - onLongClickIndex: (Int, BaseItem) -> Unit, + onShowContextMenu: (Int, BaseItem, Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + onMoveItem: (Int, UUID, MoveDirection) -> Unit, filterAndSort: FilterAndSort, onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, loadingState: LoadingState, + canEdit: Boolean, modifier: Modifier = Modifier, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } - val focus = remember { FocusRequester() } val focusedItem = items.getOrNull(focusedIndex) LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) @@ -553,9 +659,15 @@ fun PlaylistDetailsContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(top = 16.dp) .fillMaxSize(), ) { + val title = + if (loadingState is LoadingState.Success) { + playlist?.name ?: stringResource(R.string.playlist) + } else { + "" + } + GridTitle(title) Row( horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = @@ -570,120 +682,45 @@ fun PlaylistDetailsContent( filterAndSort = filterAndSort, onFilterAndSortChange = onFilterAndSortChange, getPossibleFilterValues = getPossibleFilterValues, + filterOptions = DefaultPlaylistItemsOptions, modifier = Modifier .padding(top = 80.dp) .fillMaxWidth(.25f), ) - when (loadingState) { - is LoadingState.Error -> { - ErrorMessage(loadingState, modifier) + val filterCount = + remember(filterAndSort) { + filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions) } - - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist?.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - if (items.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }.focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - isPlaying = - equalsNotNull( - musicState.currentItemId, - item?.id, - ), - isQueued = item?.id in musicState.queuedIds, - modifier = - Modifier - .ifElse( - item?.type != BaseItemKind.AUDIO, - Modifier.height(80.dp), - ).ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, - ) - } + PlaylistItems( + loadingState = loadingState, + items = items, + musicState = musicState, + playButtonFocusRequester = playButtonFocusRequester, + onFocusItem = { index, item -> + focusedIndex = index + }, + onClickItem = { index, item -> + savedIndex = index + item?.let { onClickIndex.invoke(index, item) } + }, + onShowContextMenu = { index, item, fromLongClick -> + savedIndex = index + item?.let { onShowContextMenu.invoke(index, item, fromLongClick) } + }, + canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0, + onMoveItem = onMoveItem, + modifier = + Modifier + .padding(start = 16.dp) + .weight(1f) + .focusRequester(focusRequester) + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() } - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = - Modifier - .focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }.focusRequester(focusRequester) - .focusable(), - ) - } - } - } - } - } + }, + ) } } } @@ -692,6 +729,7 @@ fun PlaylistDetailsContent( @Composable fun PlaylistDetailsHeader( focusedItem: BaseItem?, + filterOptions: List<ItemFilterBy<*>>, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, focusRequester: FocusRequester, @@ -726,7 +764,7 @@ fun PlaylistDetailsHeader( modifier = Modifier, ) { FilterByButton( - filterOptions = DefaultPlaylistItemsOptions, + filterOptions = filterOptions, current = filterAndSort.filter, onFilterChange = { onFilterAndSortChange.invoke( @@ -769,12 +807,117 @@ fun PlaylistDetailsHeader( } } +@Composable +fun PlaylistItems( + loadingState: LoadingState, + items: List<BaseItem?>, + musicState: MusicServiceState, + playButtonFocusRequester: FocusRequester, + canMove: Boolean, + onMoveItem: (Int, UUID, MoveDirection) -> Unit, + onFocusItem: (Int, BaseItem?) -> Unit, + onClickItem: (Int, BaseItem?) -> Unit, + onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + when (loadingState) { + is LoadingState.Error -> { + ErrorMessage(loadingState, modifier) + } + + LoadingState.Pending, LoadingState.Loading -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .padding(bottom = 32.dp) + .fillMaxHeight() + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), + shape = RoundedCornerShape(16.dp), + ).focusGroup() + .focusRestorer(), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + onClickItem.invoke(index, item) + }, + onLongClick = { + onShowContextMenu.invoke(index, item, true) + }, + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = item?.id in musicState.queuedIds, + canMove = canMove, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClickMove = { direction -> + item?.let { onMoveItem.invoke(index, item.id, direction) } + when (direction) { + MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) + MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) + } + }, + onClickMore = { onShowContextMenu.invoke(index, item, false) }, + modifier = + Modifier + .animateItem() + .ifElse( + item?.type != BaseItemKind.AUDIO, + Modifier.height(80.dp), + ).onFocusChanged { + if (it.hasFocus) { + onFocusItem(index, item) + } + }, + ) + } + } + } else { + LaunchedEffect(Unit) { + playButtonFocusRequester.tryRequestFocus() + } + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier, + ) + } + } + } + } +} + @Composable fun PlaylistItem( item: BaseItem?, index: Int, onClick: () -> Unit, onLongClick: () -> Unit, + canMove: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClickMove: (MoveDirection) -> Unit, + onClickMore: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, isPlaying: Boolean = false, @@ -783,78 +926,155 @@ fun PlaylistItem( val focused by interactionSource.collectIsFocusedAsState() val imageWidth = 160.dp val density = LocalDensity.current - val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } } - ListItem( - selected = false, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - trailingContent = { - item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> - val now by LocalClock.current.now - val endTimeStr = - remember(item, now) { - val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - getTimeFormatter().format(endTime) + val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + modifier = Modifier.enableMarquee(focused), + ) + }, + trailingContent = { + val duration = + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } + + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } } - Column { - Text( - text = duration.toString(), - ) - if (item.type != BaseItemKind.AUDIO) { + duration?.let { duration -> + val now by LocalClock.current.now + val context = LocalContext.current + val endTimeStr = + remember(item, now, context) { + val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) + formatTime(context, endTime) + } + val resources = LocalResources.current + val durationText = + remember(resources, duration) { resources.formatDuration(duration) } + Column { Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + text = durationText, + ) + if (item?.type != BaseItemKind.AUDIO) { + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) + if (item?.type == BaseItemKind.AUDIO) { + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } else { + val imageType = + remember(item) { + if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) { + ImageType.THUMB + } else { + ImageType.PRIMARY + } + } + ItemCardImage( + item = item, + name = item?.name, + imageType = imageType, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + modifier = Modifier.width(imageWidth), + useFallbackText = false, + fillWidth = imageWidthPx, ) } } - } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) - if (item?.type == BaseItemKind.AUDIO) { - MusicQueueMarker( - isPlaying = isPlaying, - isQueued = isQueued, + }, + modifier = Modifier.weight(1f), + ) + val contentHeight = 24.dp + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.wrapContentWidth(), + ) { + if (canMove) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, ) - } else { - ItemCardImage( - item = item, - name = item?.name, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(imageWidth), - useFallbackText = false, - fillWidth = imageWidthPx, + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, ) } } - }, - modifier = modifier, - ) + Button( + onClick = onClickMore, + enabled = true, + contentHeight = contentHeight, + ) { + Text( + text = stringResource(R.string.fa_ellipsis_vertical), + fontFamily = FontAwesome, + ) + } + } + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt index f94f7381..e38ff099 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistList.kt @@ -1,11 +1,16 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.Gravity +import androidx.compose.foundation.focusable 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,10 +30,13 @@ 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.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.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -39,6 +47,7 @@ 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.compose.ui.window.DialogWindowProvider import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme @@ -50,12 +59,16 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.MediaType +import kotlin.time.Duration.Companion.milliseconds @Composable fun PlaylistList( - playlists: List<PlaylistInfo?>, + playlists: List<PlaylistInfo>, onClick: (PlaylistInfo) -> Unit, createEnabled: Boolean, onCreatePlaylist: (String) -> Unit, @@ -69,33 +82,37 @@ fun PlaylistList( items(playlists) { playlist -> ListItem( selected = false, - enabled = playlist != null, + enabled = true, headlineContent = { Text( - text = playlist?.name ?: stringResource(R.string.loading), + text = playlist.name, ) }, supportingContent = { - if (playlist != null) { - Text( - text = playlist.mediaType.serialName, - ) - } + val id = + when (playlist.mediaType) { + MediaType.UNKNOWN -> R.string.unknown + MediaType.VIDEO -> R.string.video + MediaType.AUDIO -> R.string.audio + MediaType.PHOTO -> R.string.photos + MediaType.BOOK -> R.string.unknown + } + Text( + text = stringResource(id), + ) }, trailingContent = { - if (playlist != null) { - Text( - text = - pluralStringResource( - R.plurals.items, - playlist.count, - playlist.count, - ), - ) - } + Text( + text = + pluralStringResource( + R.plurals.items, + playlist.count, + playlist.count, + ), + ) }, onClick = { - if (playlist != null) onClick.invoke(playlist) + onClick.invoke(playlist) }, modifier = Modifier, ) @@ -113,7 +130,6 @@ fun PlaylistList( leadingContent = { Icon( imageVector = Icons.Default.Add, - tint = Color.Green.copy(.7f), contentDescription = "Add", ) }, @@ -195,16 +211,38 @@ fun PlaylistDialog( state: PlaylistLoadingState, onDismissRequest: () -> Unit, onClick: (PlaylistInfo) -> Unit, + onSearch: (String) -> Unit, createEnabled: Boolean, onCreatePlaylist: (String) -> Unit, elevation: Dp = 3.dp, ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) + + val placeholderFocusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() } + val searchFocusRequester = remember { FocusRequester() } + var query by remember { mutableStateOf("") } + var searchClicked by remember { mutableStateOf(false) } + var searchHasFocus by remember { mutableStateOf(false) } + LaunchedEffect(query) { + val previousQuery = (state as? PlaylistLoadingState.Success)?.query ?: "" + if (previousQuery != query) { + delay(750.milliseconds) + if (searchClicked) { + searchClicked = false + return@LaunchedEffect + } + onSearch.invoke(query) + } + } + Dialog( onDismissRequest = onDismissRequest, ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.TOP) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -222,6 +260,74 @@ fun PlaylistDialog( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { + if (query.isNotBlank()) { + searchClicked = true + onSearch.invoke(query) + } + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester) + .onFocusChanged { searchHasFocus = it.hasFocus } + .focusProperties { + // Block focus to except for down/next to the results + // Don't want to focus on the placeholder + up = FocusRequester.Cancel + left = FocusRequester.Cancel + right = FocusRequester.Cancel + previous = FocusRequester.Cancel + start = FocusRequester.Cancel + end = FocusRequester.Cancel + down = focusRequester + next = focusRequester + }, + ) + LaunchedEffect(Unit) { + if (state !is PlaylistLoadingState.Success) { + placeholderFocusRequester.tryRequestFocus() + } + } + // Placeholder for initial focus + // After results load, focus will move to the first one + Box( + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .width(0.dp) + .height(0.dp) + .focusRequester(placeholderFocusRequester) + .focusable(), + ) + val resultsModifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .focusProperties { + // Only focus up to search bar and ignore the placeholder + left = FocusRequester.Cancel + right = FocusRequester.Cancel + start = FocusRequester.Cancel + end = FocusRequester.Cancel + down = FocusRequester.Cancel + next = FocusRequester.Cancel + up = searchFocusRequester + previous = searchFocusRequester + onExit = { + if (requestedFocusDirection == FocusDirection.Next || + requestedFocusDirection == FocusDirection.Left || + requestedFocusDirection == FocusDirection.Right + ) { + cancelFocusChange() + } else { + searchFocusRequester.tryRequestFocus() + } + } + } when (val s = state) { PlaylistLoadingState.Pending, PlaylistLoadingState.Loading, @@ -236,20 +342,24 @@ fun PlaylistDialog( } is PlaylistLoadingState.Error -> { - ErrorMessage(s.message, s.exception) + ErrorMessage( + message = s.message, + exception = s.exception, + modifier = resultsModifier, + ) } is PlaylistLoadingState.Success -> { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + // Once results load, focus unless the user is still focused on the search box + if (!searchHasFocus) focusRequester.tryRequestFocus() + } PlaylistList( playlists = s.items, onClick = onClick, createEnabled = createEnabled, onCreatePlaylist = onCreatePlaylist, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), + modifier = resultsModifier, ) } } @@ -263,7 +373,8 @@ sealed interface PlaylistLoadingState { data object Loading : PlaylistLoadingState data class Success( - val items: List<PlaylistInfo?>, + val items: List<PlaylistInfo>, + val query: String, ) : PlaylistLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 3077727c..291ef74a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -56,7 +56,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -91,10 +90,10 @@ fun CollectionDetails( viewModel.setFavorite(itemId, favorite, position) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(it, position) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { _, _ -> @@ -273,6 +272,7 @@ fun CollectionDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -343,6 +343,7 @@ fun CollectionDetailsContent( modifier = Modifier .padding( + start = HeaderUtils.startPadding, top = HeaderUtils.topPadding, bottom = 8.dp, ).height(HeaderUtils.height), @@ -424,7 +425,7 @@ fun CollectionDetailsContent( onClickPlay = onClickPlay, modifier = Modifier.fillMaxSize(), onFocusPosition = { position -> - Timber.v("onFocusPosition=%s", position) +// Timber.v("onFocusPosition=%s", position) focusedItem = position.let { val key = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt index 757b4cf9..057c6db2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -76,7 +76,7 @@ fun CollectionMixedGrid( columns = cardViewOptions.columns, spacing = cardViewOptions.spacing.dp, bringIntoViewSpec = - remember(cardViewOptions) { + remember(cardViewOptions, density) { val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } if (cardViewOptions.showDetails) { ScrollToTopBringIntoViewSpec(spacingPx) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt index f1e85ee2..126a5765 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -51,18 +51,45 @@ fun CollectionRows( if (row is HomeRowLoadingState.Success) { // TODO not great to do this in the UI val viewOptions = - if (type == BaseItemKind.EPISODE) { - HomeRowViewOptions( - heightDp = Cards.HEIGHT_EPISODE, - episodeAspectRatio = AspectRatio.WIDE, - showTitles = cardViewOptions.showTitles, - useSeries = false, - ) - } else { - HomeRowViewOptions( - showTitles = cardViewOptions.showTitles, - ) + when (type) { + BaseItemKind.EPISODE -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + episodeAspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + useSeries = false, + ) + } + + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.PLAYLIST, + BaseItemKind.AUDIO, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.SQUARE, + showTitles = cardViewOptions.showTitles, + ) + } + + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + ) + } + + else -> { + HomeRowViewOptions( + showTitles = cardViewOptions.showTitles, + ) + } } + row.copy(viewOptions = viewOptions) } else { row diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index f5cbd741..af3f3cfd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -14,12 +14,13 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.KeyValueService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem @@ -33,19 +34,19 @@ 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.toServerString -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -89,7 +90,8 @@ class CollectionViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val imageUrlService: ImageUrlService, private val musicService: MusicService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, @Assisted private val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -135,26 +137,33 @@ class CollectionViewModel } } viewModelScope.launchDefault { - val collection = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } - backdropService.submit(collection) - val logoImageUrl = - if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { - imageUrlService.getItemImageUrl(collection, ImageType.LOGO) - } else { - null + try { + val collection = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + backdropService.submit(collection) + val logoImageUrl = + if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { + imageUrlService.getItemImageUrl(collection, ImageType.LOGO) + } else { + null + } + _state.update { + it.copy( + collection = collection, + logoImageUrl = logoImageUrl, + ) } - _state.update { - it.copy( - collection = collection, - logoImageUrl = logoImageUrl, - ) + listenForStateUpdates() + themeSongPlayer.playThemeFor(itemId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting collection %s", itemId) + _state.update { it.copy(loadingState = LoadingState.Error(ex)) } } - listenForStateUpdates() - themeSongPlayer.playThemeFor(itemId) } } @@ -178,6 +187,8 @@ class CollectionViewModel .collectLatest { (sort, filter, separateTypes) -> try { updateData(sort, filter, separateTypes) + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e( ex, @@ -209,12 +220,16 @@ class CollectionViewModel supervisorScope { val jobs = typesInCollection.map { type -> - async(Dispatchers.IO) { + async(WholphinDispatchers.IO) { val title = ResStringProvider(formatTypeName(type)) val result = try { val pager = fetchItems(sort, filter, listOf(type)) - HomeRowLoadingState.Success(title, pager) + HomeRowLoadingState.Success( + title, + pager, + showViewMore = false, + ) } catch (ex: Exception) { Timber.e( ex, @@ -348,15 +363,13 @@ class CollectionViewModel } suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) suspend fun letterPosition(letter: Char): Int = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val sort = state.value.sortAndDirection val filter = state.value.itemFilter val request = @@ -384,7 +397,7 @@ class CollectionViewModel itemId: UUID, played: Boolean, position: RowColumn?, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) if (itemId == state.value.collection?.id) { refreshCollection() @@ -397,7 +410,7 @@ class CollectionViewModel itemId: UUID, favorite: Boolean, position: RowColumn?, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (itemId == state.value.collection?.id) { refreshCollection() @@ -486,6 +499,9 @@ class CollectionViewModel BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_VIDEO, ) const val VIEW_OPTIONS_KEY = "CollectionViewOptions" 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 d6bcd152..e91dfb82 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 @@ -53,10 +53,8 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -67,7 +65,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull @Composable fun DiscoverMovieDetails( @@ -90,7 +87,7 @@ fun DiscoverMovieDetails( val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var moreDialog by remember { mutableStateOf<DialogParams?>(null) } + var showRequestDialog by remember { mutableStateOf(false) } val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) @@ -119,28 +116,8 @@ fun DiscoverMovieDetails( similar = state.similar, recommended = state.recommended, requestOnClick = { - movie.id?.let { id -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), - ), - ) - } else { - viewModel.request(id, false) - } - } + viewModel.requestOnClick() + showRequestDialog = true }, cancelOnClick = { movie.id?.let { viewModel.cancelRequest(it) } @@ -161,23 +138,9 @@ fun DiscoverMovieDetails( ) }, goToOnClick = { - movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } - }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = listOf(), - ) + viewModel.goTo(movie.mediaInfo, BaseItemKind.MOVIE) }, + moreOnClick = {}, onLongClickPerson = { index, person -> }, onLongClickSimilar = { index, similar -> }, @@ -186,6 +149,19 @@ fun DiscoverMovieDetails( }, modifier = modifier, ) + if (showRequestDialog) { + RequestMovieDialog( + loading = state.profileLoading, + data = state.requestData, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = { + viewModel.request(it) + showRequestDialog = false + }, + onDismissRequest = { showRequestDialog = false }, + ) + } } } overviewDialog?.let { info -> @@ -195,16 +171,6 @@ fun DiscoverMovieDetails( onDismissRequest = { overviewDialog = null }, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } } private const val HEADER_ROW = 0 @@ -265,10 +231,11 @@ fun DiscoverMovieDetailsContent( rating = rating, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( availability = @@ -390,7 +357,7 @@ fun TrailerRow( modifier = modifier, ) { Text( - text = stringResource(R.string.trailers), + text = stringResource(R.string.trailers_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) 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 60d3806b..1c0915ec 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 @@ -11,9 +11,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -22,15 +22,21 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.ImageType import java.util.Locale import kotlin.time.Duration.Companion.minutes @@ -41,39 +47,46 @@ fun DiscoverMovieDetailsHeader( rating: DiscoverRating?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, ) { + val imageUrlService = LocalImageUrlService.current + val logoImageUrl = + remember(movie) { + movie.mediaInfo?.jellyfinId?.let { + imageUrlService.getItemImageUrl(it, ImageType.LOGO) + } + } val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - // Title - Text( - text = movie.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + TitleOrLogo( + title = movie.title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp + val resources = LocalResources.current val details = - remember(movie, rating) { + remember(movie, rating, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime ?.toDouble() ?.minutes ?.roundMinutes - ?.toString() - ?.let(::add) + ?.let { add(resources.formatDuration(it)) } val release = movie.releases ?.results @@ -100,9 +113,9 @@ fun DiscoverMovieDetailsHeader( } } - QuickDetailsText(details) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) movie.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } @@ -111,7 +124,7 @@ fun DiscoverMovieDetailsHeader( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier, + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } @@ -148,6 +161,7 @@ fun DiscoverMovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } } 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 f80457fc..20cecd12 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.MediaRequest import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo @@ -11,6 +12,8 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.RequestStatus +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.hasPermission @@ -19,10 +22,15 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.ui.equalsNotNull 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.showToast import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -30,7 +38,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -39,6 +46,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException @@ -72,7 +80,7 @@ class DiscoverMovieViewModel } private fun fetchAndSetItem(): Deferred<MovieDetails?> = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { try { val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) _state.update { it.copy(movie = DataLoadingState.Success(movie)) } @@ -97,9 +105,13 @@ class DiscoverMovieViewModel updateCanCancel() viewModelScope.launchIO { - val result = - seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) - _state.update { it.copy(rating = DiscoverRating(result)) } + val rating = + getDiscoverRating(item.id) { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id), + ) + } + _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { viewModelScope.launchIO { @@ -163,19 +175,44 @@ class DiscoverMovieViewModel navigationManager.navigateTo(destination) } - fun request( - id: Int, - is4k: Boolean, + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) + } + } + + fun request(request: MovieRequest) { viewModelScope.launchIO { - val request = + if (request.movieId == null) { + Timber.w("Null movie ID") + return@launchIO + } + try { seerrService.api.requestApi.requestPost( RequestPostRequest( - is4k = is4k, - mediaId = id, + is4k = request.is4k, + mediaId = request.movieId, mediaType = RequestPostRequest.MediaType.MOVIE, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + tags = emptyList(), ), ) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", request.movieId) + showToast(context, "An error occurred") + } fetchAndSetItem().await() updateCanCancel() } @@ -185,12 +222,41 @@ class DiscoverMovieViewModel viewModelScope.launchIO { state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? - seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + try { + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + } catch (ex: kotlinx.coroutines.CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } fetchAndSetItem().await() updateCanCancel() } } } + + fun requestOnClick() { + viewModelScope.launchIO { + try { + val data = seerrService.getProfilesAndFolders(SeerrItemType.MOVIE) + _state.update { + it.copy( + profileLoading = LoadingState.Success, + requestData = data, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting profiles & folders") + showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}") + _state.update { + it.copy( + profileLoading = LoadingState.Success, + ) + } + } + } + } } fun canUserCancelRequest( @@ -200,7 +266,12 @@ fun canUserCancelRequest( ( // User requested this user.hasPermission(SeerrPermission.REQUEST) && - requests?.any { it.requestedBy?.id == user?.id } == true + requests + .orEmpty() + .any { + equalsNotNull(it.requestedBy?.id, user?.id) && + RequestStatus.from(it.status) < RequestStatus.APPROVED + } ) data class DiscoverMovieState( @@ -212,4 +283,6 @@ data class DiscoverMovieState( val similar: List<DiscoverItem> = emptyList(), val recommended: List<DiscoverItem> = emptyList(), val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt index 5987b7eb..2ce4a3a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -61,20 +61,7 @@ class DiscoverPersonViewModel viewModelScope.launchIO { backdropService.clearBackdrop() - val credits = - seerrService.api.personApi - .personPersonIdCombinedCreditsGet(personId = item.id) - .let { credits -> - val cast = - credits.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - val crew = - credits.crew - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - cast + crew - } + val credits = seerrService.personCredits(item.id) this@DiscoverPersonViewModel.credits.update { DataLoadingState.Success(credits) } @@ -85,6 +72,7 @@ class DiscoverPersonViewModel @Composable fun DiscoverPersonPage( person: DiscoverItem, + startIndex: Int, modifier: Modifier = Modifier, viewModel: DiscoverPersonViewModel = hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>( @@ -137,6 +125,7 @@ fun DiscoverPersonPage( }, onClickPlay = { _, item -> }, + initialPosition = startIndex, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, 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 a493cab3..049f7610 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 @@ -24,16 +24,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem @@ -46,6 +42,8 @@ import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -54,11 +52,14 @@ import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.nav.Destination @@ -70,7 +71,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.successValue import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.jellyfin.sdk.model.api.ImageType import kotlin.time.Duration.Companion.minutes @Composable @@ -86,16 +87,13 @@ fun DiscoverSeriesDetails( val context = LocalContext.current val resources = LocalResources.current val state by viewModel.state.collectAsState() - val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) + val request4kEnabled by viewModel.request4kEnabled.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var showRequestSeasonDialog by remember { mutableStateOf(false) } - val requestStr = stringResource(R.string.request) - val request4kStr = stringResource(R.string.request_4k) - when (val st = state.tvSeries) { is DataLoadingState.Error -> { ErrorMessage(st, modifier) @@ -128,14 +126,7 @@ fun DiscoverSeriesDetails( viewModel.navigateTo(Destination.DiscoveredItem(it)) }, goToOnClick = { - item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } + viewModel.goTo(item.mediaInfo, BaseItemKind.SERIES) }, overviewOnClick = { overviewDialog = @@ -151,9 +142,8 @@ fun DiscoverSeriesDetails( }, trailers = state.trailers, requestOnClick = { - item.id?.let { id -> - showRequestSeasonDialog = true - } + viewModel.requestOnClick() + showRequestSeasonDialog = true }, cancelOnClick = { item.id?.let { viewModel.cancelRequest(it) } @@ -193,15 +183,17 @@ fun DiscoverSeriesDetails( } if (showRequestSeasonDialog) { RequestSeasonsDialog( + id = state.tvSeries.successValue?.id ?: -1, title = state.tvSeries.successValue?.name ?: "", seasons = state.seasons, + seasons4k = state.seasons4k, request4kEnabled = request4kEnabled, - onSubmit = { seasons, is4k -> - state.tvSeries.successValue - ?.id - ?.let { viewModel.request(it, seasons, is4k) } + onSubmit = { showRequestSeasonDialog = false + viewModel.request(it) }, + loading = state.profileLoading, + data = state.requestData, onDismissRequest = { showRequestSeasonDialog = false }, ) } @@ -256,7 +248,6 @@ fun DiscoverSeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) .fillMaxSize(), ) { LazyColumn( @@ -276,10 +267,11 @@ fun DiscoverSeriesDetailsContent( series = series, rating = rating, overviewOnClick = overviewOnClick, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( availability = @@ -432,26 +424,36 @@ fun DiscoverSeriesDetailsHeader( series: TvDetails, rating: DiscoverRating?, overviewOnClick: () -> Unit, + showLogo: Boolean, modifier: Modifier = Modifier, ) { + val imageUrlService = LocalImageUrlService.current + val logoImageUrl = + remember(series) { + series.mediaInfo?.jellyfinId?.let { + imageUrlService.getItemImageUrl(it, ImageType.LOGO) + } + } 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, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + TitleOrLogo( + title = series.name, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp + val resources = LocalResources.current val details = - remember(series, rating) { + remember(series, rating, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -459,8 +461,7 @@ fun DiscoverSeriesDetailsHeader( ?.takeIf { !it.isNaN() && it > 0 } ?.minutes ?.roundMinutes - ?.toString() - ?.let(::add) + ?.let { add(resources.formatDuration(it)) } // TODO }.let { listToDotString( @@ -471,9 +472,9 @@ fun DiscoverSeriesDetailsHeader( } } - QuickDetailsText(details) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) series.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } series.overview?.let { overview -> OverviewText( @@ -518,7 +519,6 @@ fun buildDialogForSeason( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index fc40b0c4..ec500351 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest @@ -11,16 +12,22 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService 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.showToast import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -29,16 +36,18 @@ 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.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @@ -63,14 +72,17 @@ class DiscoverSeriesViewModel val state: StateFlow<DiscoverSeriesState> = _state val userConfig = seerrServerRepository.current.map { it?.config } - val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } + val request4kEnabled = + seerrServerRepository.current + .map { it?.request4kTvEnabled ?: false } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { init() } private fun fetchAndSetItem(): Deferred<TvDetails?> = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { try { val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } @@ -96,8 +108,11 @@ class DiscoverSeriesViewModel updateCanCancel() viewModelScope.launchIO { - val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) - _state.update { it.copy(rating = DiscoverRating(result)) } + val rating = + getDiscoverRating(item.id) { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } + _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { viewModelScope.launchIO { @@ -150,39 +165,133 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } - private fun updateSeasonStatus(tv: TvDetails) { - val seasonStatus = mutableMapOf<Int, SeerrAvailability>() + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, + ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) + } + } + + private suspend fun updateSeasonStatus(tv: TvDetails) { + if (request4kEnabled.first()) { + updateSeasonStatus(tv, true) + } + updateSeasonStatus(tv, false) + } + + private suspend fun updateSeasonStatus( + tv: TvDetails, + is4k: Boolean, + ) { + val currentUserId = seerrServerRepository.currentUserId.first() + val seasonStatus = mutableMapOf<Int, RequestStatus>() + val seasonAvailability = mutableMapOf<Int, SeerrAvailability>() + val editable = mutableMapOf<Int, Boolean>() tv.seasons?.forEach { - it.seasonNumber?.let { - seasonStatus[it] = SeerrAvailability.UNKNOWN + it.seasonNumber?.let { seasonNumber -> + seasonStatus[seasonNumber] = RequestStatus.UNKNOWN + val status = if (is4k) it.status4k else it.status + val availability = + SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN + seasonAvailability[seasonNumber] = availability } } - val tvStatus = - SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN + tv.mediaInfo?.seasons?.forEach { + it.seasonNumber?.let { seasonNumber -> + val status = if (is4k) it.status4k else it.status + val availability = + SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN + val current = + seasonAvailability.getOrDefault(seasonNumber, SeerrAvailability.UNKNOWN) + if (availability > current) { + seasonAvailability[seasonNumber] = availability + } + } + } + tv.mediaInfo ?.requests - ?.forEach { - it.seasons?.mapNotNull { season -> + ?.filter { it.is4k == is4k } + ?.forEach { req -> + req.seasons?.mapNotNull { season -> season.seasonNumber?.let { val current = seasonStatus[season.seasonNumber] - val new = - SeerrAvailability - .from(season.status) - ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus + // Not status4k because the request itself is marked as is4k or not + val status = season.status + val new = RequestStatus.from(status) if (current == null || new.status > current.status) { seasonStatus[season.seasonNumber] = new } + editable[season.seasonNumber] = + currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status } } } + Timber.v("seasonAvailability=%s", seasonAvailability) Timber.v("seasonStatus=%s", seasonStatus) val requestSeasons = - seasonStatus.mapNotNull { (seasonNumber, availability) -> + seasonStatus.mapNotNull { (seasonNumber, status) -> tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { - RequestSeason(it, availability) + val availability = + when (status) { + RequestStatus.PENDING -> { + SeerrAvailability.PENDING + } + + RequestStatus.APPROVED -> { + SeerrAvailability.PROCESSING + } + + RequestStatus.DECLINED -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.FAILURE -> { + SeerrAvailability.UNKNOWN + } + + RequestStatus.UNKNOWN, + RequestStatus.COMPLETED, + -> { + seasonAvailability.getOrDefault( + seasonNumber, + SeerrAvailability.UNKNOWN, + ) + } + } + val defaultEditable = + availability != SeerrAvailability.AVAILABLE && + availability != SeerrAvailability.PARTIALLY_AVAILABLE && + availability != SeerrAvailability.PROCESSING && + availability != SeerrAvailability.BLOCKLISTED + RequestSeason( + season = it, + status = status, + availability = availability, + editable = editable.getOrDefault(seasonNumber, defaultEditable), + ) } } - _state.update { it.copy(seasons = requestSeasons) } + Timber.v("Got %s seasons, is4k=%s", requestSeasons.size, is4k) +// requestSeasons.forEach { +// Timber.v( +// "is4k=%s, season %s: availability=%s, status=%s, editable=%s", +// is4k, +// it.season.seasonNumber, +// it.availability, +// it.status, +// it.editable, +// ) +// } + _state.update { + if (is4k) { + it.copy(seasons4k = requestSeasons) + } else { + it.copy(seasons = requestSeasons) + } + } } private suspend fun updateCanCancel() { @@ -197,39 +306,82 @@ class DiscoverSeriesViewModel _state.update { it.copy(canCancelRequest = canCancel) } } - fun request( - id: Int, - seasons: Set<Int>, - is4k: Boolean, - ) { + fun requestOnClick() { + viewModelScope.launchIO { + try { + val data = seerrService.getProfilesAndFolders(SeerrItemType.TV) + _state.update { + it.copy( + profileLoading = LoadingState.Success, + requestData = data, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting profiles & folders") + showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}") + _state.update { + it.copy( + profileLoading = LoadingState.Success, + ) + } + } + } + } + + fun request(request: TvRequest) { viewModelScope.launchIO { state.value.tvSeries.successValue?.let { tv -> + val currentUserId = seerrServerRepository.currentUserId.first() val currentRequest = tv.mediaInfo?.requests?.firstOrNull { - it.requestedBy?.id == - seerrServerRepository.currentUserId.first() + it.status == RequestStatus.PENDING.status && + it.requestedBy?.id == currentUserId } - if (currentRequest != null) { - Timber.v("User has pending request, will update") - seerrService.api.requestApi.requestRequestIdPut( - requestId = currentRequest.id.toString(), - requestRequestIdPutRequest = - RequestRequestIdPutRequest( - is4k = is4k, - mediaType = RequestRequestIdPutRequest.MediaType.TV, - seasons = seasons.toList(), + try { + if (currentRequest != null) { + Timber.v("User has pending request, will update") + seerrService.api.requestApi.requestRequestIdPut( + requestId = currentRequest.id.toString(), + requestRequestIdPutRequest = + RequestRequestIdPutRequest( + is4k = request.is4k, + mediaType = RequestRequestIdPutRequest.MediaType.TV, + seasons = request.seasons, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + ), + ) + } else { + Timber.v("New request for %s seasons", request.seasons.size) + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = request.is4k, + mediaId = request.tvId, + mediaType = RequestPostRequest.MediaType.TV, + seasons = request.seasons, + serverId = + when { + request.profileId == null && request.folder == null -> null + request.is4k -> request.data.server4kId + else -> request.data.serverId + }, + profileId = request.profileId, + rootFolder = request.folder, + tags = emptyList(), ), - ) - } else { - Timber.v("New request for %s seasons", seasons.size) - seerrService.api.requestApi.requestPost( - RequestPostRequest( - is4k = is4k, - mediaId = id, - mediaType = RequestPostRequest.MediaType.TV, - seasons = seasons.toList(), - ), - ) + ) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", request.tvId) + showToast(context, "An error occurred") } fetchAndSetItem().await()?.let { @@ -244,7 +396,15 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? - seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + try { + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error requesting %s", id) + showToast(context, "An error occurred") + } + fetchAndSetItem().await()?.let { updateSeasonStatus(it) updateCanCancel() @@ -258,9 +418,12 @@ data class DiscoverSeriesState( val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending, val rating: DiscoverRating? = null, val seasons: List<RequestSeason> = emptyList(), + val seasons4k: List<RequestSeason> = emptyList(), val trailers: List<Trailer> = emptyList(), val people: List<DiscoverItem> = emptyList(), val similar: List<DiscoverItem> = emptyList(), val recommended: List<DiscoverItem> = emptyList(), val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt new file mode 100644 index 00000000..3e625d60 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt @@ -0,0 +1,72 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.MediaInfo +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.jellyfinId +import com.github.damontecres.wholphin.services.jellyfinIdAsString +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.showToast +import kotlinx.coroutines.CancellationException +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber + +suspend fun getDiscoverRating( + id: Int, + func: suspend () -> DiscoverRating, +): DiscoverRating? = + try { + func.invoke() + } catch (ex: CancellationException) { + throw ex + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + Timber.w("No rating for %s", id) + } else { + Timber.e(ex, "Error fetching rating for %s", id) + } + null + } catch (ex: Exception) { + Timber.e(ex, "Error fetching rating for %s", id) + null + } + +/** + * Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId] + * + * If not, a error toast is shown + */ +suspend fun goToButtonDiscover( + mediaInfo: MediaInfo?, + type: BaseItemKind, + context: Context, + navigationManager: NavigationManager, +) { + val id = mediaInfo?.jellyfinId + if (id != null) { + navigationManager.navigateTo( + Destination.MediaItem( + itemId = id, + type = type, + ), + ) + } else { + val stringId = mediaInfo?.jellyfinIdAsString + val msg = + if (stringId.isNotNullOrBlank()) { + "Unknown Jellyfin ID: $stringId" + } else { + "No Jellyfin ID found" + } + Timber.w( + "Unknown jellyfinId for tmdb=%s: %s/%s", + mediaInfo?.tmdbId, + mediaInfo?.jellyfinMediaId, + mediaInfo?.jellyfinMediaId4k, + ) + showToast(context, msg) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt index 2c3fc036..c5921b3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -60,7 +60,11 @@ fun ExpandableDiscoverButtons( SeerrAvailability.AVAILABLE, -> R.string.go_to - SeerrAvailability.DELETED -> R.string.delete // TODO + // TODO + SeerrAvailability.DELETED -> R.string.delete + + // TODO Blocklist + SeerrAvailability.BLOCKLISTED -> R.string.unavailable } val icon = when (availability) { @@ -74,13 +78,29 @@ fun ExpandableDiscoverButtons( SeerrAvailability.AVAILABLE, -> R.string.fa_play - SeerrAvailability.DELETED -> R.string.fa_video // TODO + SeerrAvailability.DELETED -> R.string.fa_video + + // TODO + SeerrAvailability.BLOCKLISTED -> R.string.fa_xmark } item("first") { ExpandableFaButton( title = text, iconStringRes = icon, - enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true, + enabled = + when (availability) { + SeerrAvailability.UNKNOWN -> canRequest + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> true + + SeerrAvailability.BLOCKLISTED, + SeerrAvailability.DELETED, + -> false + }, onClick = { when (availability) { SeerrAvailability.UNKNOWN -> { @@ -99,7 +119,9 @@ fun ExpandableDiscoverButtons( goToOnClick.invoke() } - SeerrAvailability.DELETED -> { + SeerrAvailability.DELETED, + SeerrAvailability.BLOCKLISTED, + -> { // TODO } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt new file mode 100644 index 00000000..7a009f09 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt @@ -0,0 +1,397 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +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.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DialogPopupContent +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun RequestMovieDialog( + loading: LoadingState, + data: SeerrRequestData, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + RequestMovie( + loading = loading, + data = data, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = onSubmit, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun RequestMovie( + loading: LoadingState, + data: SeerrRequestData, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + modifier: Modifier = Modifier, +) { + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) + if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && + data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() + ) { + if (request4kEnabled) { + val items = + remember { + listOf( + DialogItem( + text = requestStr, + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + }, + ), + DialogItem( + text = request4kStr, + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, true, null, null)) + }, + ), + ) + } + DialogPopupContent( + title = movie.title + " (${movie.releaseDate ?: ""})", + dialogItems = items, + waiting = false, + onDismissRequest = {}, + dismissOnClick = false, + modifier = modifier.focusRequester(focusRequester), + ) + } else { + LaunchedEffect(Unit) { + onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + } + } + } else { + RequestMovieWithOptions( + data = data, + request4kEnabled = request4kEnabled, + movie = movie, + onSubmit = onSubmit, + modifier = modifier.focusRequester(focusRequester), + ) + } + } + } +} + +@Composable +private fun RequestMovieWithOptions( + data: SeerrRequestData, + request4kEnabled: Boolean, + movie: MovieDetails, + onSubmit: (MovieRequest) -> Unit, + modifier: Modifier = Modifier, +) { + var is4k by remember { mutableStateOf(request4kEnabled) } + var profile by remember(is4k) { + mutableStateOf( + if (is4k) { + data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull() + } else { + data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull() + }, + ) + } + var folder by remember(is4k) { + mutableStateOf( + if (is4k) { + data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull() + } else { + data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull() + }, + ) + } + val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles } + val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = movie.title + " (${movie.releaseDate ?: ""})", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier, + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + if (request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + Button( + onClick = { + onSubmit.invoke(MovieRequest(data, movie.id, is4k, profile?.id, folder?.path)) + }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + + if (profiles.isNotEmpty()) { + profile?.let { + item { + ChooseProfile( + selectedProfile = it, + profiles = profiles, + onClickProfile = { profile = it }, + modifier = Modifier, + ) + } + } + } + if (folders.isNotEmpty()) { + folder?.let { + item { + ChooseFolder( + selectedFolder = it, + folders = folders, + onClickFolder = { folder = it }, + modifier = Modifier, + ) + } + } + } + } + } +} + +@Composable +fun ChooseProfile( + selectedProfile: SeerrProfile, + profiles: List<SeerrProfile>, + onClickProfile: (SeerrProfile) -> Unit, + modifier: Modifier = Modifier, +) { + val defaultStr = stringResource(R.string.is_default) + val qualityProfileStr = stringResource(R.string.quality_profile) + var showProfileDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + headlineContent = { + Text(qualityProfileStr) + }, + supportingContent = { + val text = + remember(selectedProfile) { + if (selectedProfile.default) "${selectedProfile.name} ($defaultStr)" else selectedProfile.name + } + Text(text) + }, + onClick = { showProfileDialog = true }, + modifier = modifier, + ) + if (showProfileDialog) { + val params = + remember { + val items = + profiles.map { + DialogItem( + headlineContent = { + Text(it.name) + }, + supportingContent = { + if (it.default) { + Text(defaultStr) + } + }, + leadingContent = { + SelectedLeadingContent(it == selectedProfile) + }, + onClick = { onClickProfile.invoke(it) }, + ) + } + DialogParams( + fromLongClick = false, + title = qualityProfileStr, + items = items, + ) + } + DialogPopup( + params = params, + onDismissRequest = { showProfileDialog = false }, + dismissOnClick = true, + elevation = 3.dp, + ) + } +} + +@Composable +fun ChooseFolder( + selectedFolder: SeerrRootFolder, + folders: List<SeerrRootFolder>, + onClickFolder: (SeerrRootFolder) -> Unit, + modifier: Modifier = Modifier, +) { + val defaultStr = stringResource(R.string.is_default) + val rootFolderStr = stringResource(R.string.root_folder) + var showFolderDialog by remember { mutableStateOf(false) } + ListItem( + selected = false, + headlineContent = { + Text(rootFolderStr) + }, + supportingContent = { + val text = + remember(selectedFolder) { + if (selectedFolder.default) "${selectedFolder.path} ($defaultStr)" else selectedFolder.path + } + Text(text) + }, + trailingContent = { + Text(selectedFolder.freeSpace) + }, + onClick = { showFolderDialog = true }, + modifier = modifier, + ) + if (showFolderDialog) { + val params = + remember { + val items = + folders.map { + DialogItem( + headlineContent = { + Text(it.path) + }, + supportingContent = { + if (it.default) { + Text(defaultStr) + } + }, + trailingContent = { + Text(it.freeSpace) + }, + leadingContent = { + SelectedLeadingContent(it == selectedFolder) + }, + onClick = { onClickFolder.invoke(it) }, + ) + } + DialogParams( + fromLongClick = false, + title = rootFolderStr, + items = items, + ) + } + DialogPopup( + params = params, + onDismissRequest = { showFolderDialog = false }, + dismissOnClick = true, + elevation = 3.dp, + ) + } +} + +@PreviewTvSpec +@Composable +fun MovieRequestPreview() { + WholphinTheme { + RequestMovie( + loading = LoadingState.Success, + data = + SeerrRequestData( + profiles = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders = + listOf( + SeerrRootFolder(1, "/movies", "400GB", true), + ), + profiles4k = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders4k = + listOf( + SeerrRootFolder(1, "/movies", "400GB", true), + ), + ), + request4kEnabled = false, + movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"), + onSubmit = {}, + modifier = + Modifier + .width(320.dp) + .padding(16.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index 4a56cb88..bc11825f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateSetOf @@ -20,9 +22,10 @@ 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.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button @@ -35,73 +38,139 @@ import androidx.tv.material3.Text import androidx.tv.material3.contentColorFor import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.ui.cards.AvailableIndicator import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator import com.github.damontecres.wholphin.ui.cards.PendingIndicator import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState data class RequestSeason( val season: Season, + val status: RequestStatus, val availability: SeerrAvailability, + val editable: Boolean, ) @Composable fun RequestSeasons( + id: Int, title: String, seasons: List<RequestSeason>, - onSubmit: (Set<Int>, Boolean) -> Unit, + seasons4k: List<RequestSeason>, + data: SeerrRequestData, request4kEnabled: Boolean, - modifier: Modifier, + onSubmit: (TvRequest) -> Unit, + modifier: Modifier = Modifier, ) { - val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } - val selected = - remember { - mutableStateSetOf<Int>( + var is4k by remember { mutableStateOf(request4kEnabled) } + val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } + + val allSeasonNumbers = + remember(seasons) { + seasons + .filter { it.editable } + .mapNotNull { it.season.seasonNumber } + .toSet() + } + val availableSeasons = + remember(seasons) { + mutableStateSetOf( *seasons - .mapNotNull { - if (it.availability > SeerrAvailability.UNKNOWN) { - it.season.seasonNumber - } else { - null - } - }.toTypedArray(), + .filter { season -> + !season.editable && ( + season.status == RequestStatus.PENDING || + season.status == RequestStatus.APPROVED || + season.status == RequestStatus.COMPLETED || + season.availability == SeerrAvailability.PARTIALLY_AVAILABLE || + season.availability == SeerrAvailability.AVAILABLE + ) + }.mapNotNull { season -> season.season.seasonNumber } + .toTypedArray(), ) } - var is4k by remember { mutableStateOf(false) } + val selectedSeasons = + remember(seasons) { + mutableStateSetOf<Int>( + *seasons + .filter { season -> season.status == RequestStatus.PENDING } + .mapNotNull { season -> season.season.seasonNumber } + .toTypedArray(), + ) + } + + var profile by remember(is4k) { + mutableStateOf( + if (is4k) { + data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull() + } else { + data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull() + }, + ) + } + var folder by remember(is4k) { + mutableStateOf( + if (is4k) { + data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull() + } else { + data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull() + }, + ) + } + val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles } + val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders } + + fun submit() { + onSubmit.invoke( + TvRequest( + data = data, + tvId = id, + seasons = selectedSeasons.toList(), + is4k = is4k, + profileId = profile?.id, + folder = folder?.path, + ), + ) + } + Column( - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Text( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier, ) - LazyColumn { + LazyColumn( + modifier = Modifier, + ) { item { - val isSelected = selected.containsAll(allSeasonNumbers) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { - ClickSwitch( - label = stringResource(R.string.select_all), - checked = isSelected, - onClick = { - if (isSelected) { - selected.removeAll(allSeasonNumbers) - } else { - selected.addAll(allSeasonNumbers) - } - }, - ) + if (request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } Button( - onClick = { onSubmit.invoke(selected, is4k) }, + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), ) { Text( text = stringResource(R.string.submit), @@ -109,39 +178,80 @@ fun RequestSeasons( } } } - if (request4kEnabled) { - item { - ClickSwitch( - label = stringResource(R.string.request_4k), - checked = is4k, - onClick = { is4k = !is4k }, - ) + if (profiles.isNotEmpty()) { + profile?.let { + item { + ChooseProfile( + selectedProfile = it, + profiles = profiles, + onClickProfile = { profile = it }, + modifier = Modifier, + ) + } } } + if (folders.isNotEmpty()) { + folder?.let { + item { + ChooseFolder( + selectedFolder = it, + folders = folders, + onClickFolder = { folder = it }, + modifier = Modifier, + ) + } + } + } + item { + HorizontalDivider() + } + item { + Text( + text = stringResource(R.string.tv_seasons), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val isSelected = selectedSeasons.containsAll(allSeasonNumbers) + ClickSwitch( + label = stringResource(R.string.select_all), + checked = isSelected, + onClick = { + if (isSelected) { + selectedSeasons.removeAll(allSeasonNumbers) + } else { + selectedSeasons.addAll(allSeasonNumbers) + } + }, + ) + } itemsIndexed(seasons) { index, season -> val seasonNumber = season.season.seasonNumber - val isSelected = seasonNumber in selected + val checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons SeasonListItem( season = season, - selected = isSelected, + checked = checked, onClick = { - if (isSelected) { - selected.remove(seasonNumber) + if (checked) { + selectedSeasons.remove(seasonNumber) } else { - seasonNumber?.let { selected.add(it) } + seasonNumber?.let { selectedSeasons.add(it) } } }, modifier = Modifier, ) } - if (seasons.size > 7) { + if (seasons.size > 3) { item { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), ) { Button( - onClick = { onSubmit.invoke(selected, is4k) }, + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), ) { Text( text = stringResource(R.string.submit), @@ -157,17 +267,19 @@ fun RequestSeasons( @Composable fun SeasonListItem( season: RequestSeason, - selected: Boolean, + checked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { ListItem( + enabled = season.editable, selected = false, headlineContent = { Text( text = season.season.name - ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ?: season.season.seasonNumber?.let { formatSeasonNumber(it) } + ?: "", ) }, supportingContent = { @@ -180,9 +292,10 @@ fun SeasonListItem( }, leadingContent = { when (season.availability) { - SeerrAvailability.UNKNOWN -> {} - - SeerrAvailability.DELETED -> {} + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + -> { + } SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, @@ -197,12 +310,17 @@ fun SeasonListItem( SeerrAvailability.AVAILABLE -> { AvailableIndicator() } + + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed + // BlocklistedIndicator() + } } }, trailingContent = { Row { Switch( - checked = selected, + checked = checked, onCheckedChange = { onClick.invoke() }, @@ -237,7 +355,7 @@ private fun ClickSurface( } @Composable -private fun ClickSwitch( +fun ClickSwitch( label: String, checked: Boolean, onClick: () -> Unit, @@ -267,22 +385,48 @@ private fun ClickSwitch( @Composable fun RequestSeasonsDialog( + id: Int, title: String, + loading: LoadingState, + data: SeerrRequestData, seasons: List<RequestSeason>, + seasons4k: List<RequestSeason>, request4kEnabled: Boolean, - onSubmit: (Set<Int>, Boolean) -> Unit, + onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, ) { BasicDialog( onDismissRequest = onDismissRequest, ) { - RequestSeasons( - title = title, - seasons = seasons, - request4kEnabled = request4kEnabled, - onSubmit = onSubmit, - modifier = Modifier.padding(16.dp), - ) + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading, Modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(Modifier) + } + + LoadingState.Success -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + RequestSeasons( + id = id, + title = title, + data = data, + seasons = seasons, + seasons4k = seasons4k, + request4kEnabled = request4kEnabled, + onSubmit = onSubmit, + modifier = + Modifier + .padding(16.dp) + .focusRequester(focusRequester), + ) + } + } } } @@ -302,21 +446,37 @@ fun RequestSeasonsPreview() { seasonNumber = it + 1, episodeCount = 10 + it, ), + status = RequestStatus.UNKNOWN, availability = if (it < 3) { SeerrAvailability.AVAILABLE } else { SeerrAvailability.UNKNOWN }, + editable = it >= 3, ) } WholphinTheme { RequestSeasons( + id = 1, title = "Series title", seasons = seasons, + seasons4k = emptyList(), + data = + SeerrRequestData( + profiles4k = + listOf( + SeerrProfile(1, "HD", true), + SeerrProfile(2, "Ultra HD", false), + ), + rootFolders4k = + listOf( + SeerrRootFolder(1, "/tv", "400GB", true), + ), + ), request4kEnabled = true, - onSubmit = { _, _ -> }, + onSubmit = { }, modifier = Modifier.width(400.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt new file mode 100644 index 00000000..355049dc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt @@ -0,0 +1,40 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +data class SeerrRequestData( + val serverId: Int = -1, + val server4kId: Int = -1, + val profiles: List<SeerrProfile> = emptyList(), + val rootFolders: List<SeerrRootFolder> = emptyList(), + val profiles4k: List<SeerrProfile> = emptyList(), + val rootFolders4k: List<SeerrRootFolder> = emptyList(), +) + +data class SeerrProfile( + val id: Int, + val name: String, + val default: Boolean, +) + +data class SeerrRootFolder( + val id: Int, + val path: String, + val freeSpace: String, + val default: Boolean, +) + +data class MovieRequest( + val data: SeerrRequestData, + val movieId: Int?, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) + +data class TvRequest( + val data: SeerrRequestData, + val tvId: Int, + val seasons: List<Int>, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) 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 fa45058d..b9956fb4 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 @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog @@ -47,7 +48,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -86,10 +87,10 @@ fun EpisodeDetails( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onChooseVersion = { item, source -> @@ -169,6 +170,7 @@ fun EpisodeDetails( }, canDelete = canDelete, onConfirmDelete = { viewModel.deleteItem(ep) }, + onChooseVersion = { contextActions.onChooseVersion.invoke(ep, it) }, modifier = modifier, ) } @@ -205,6 +207,7 @@ fun EpisodeDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -224,6 +227,7 @@ fun EpisodeDetailsContent( moreOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -284,6 +288,14 @@ fun EpisodeDetailsContent( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, 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 1c6249da..a45fbda0 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 @@ -11,8 +11,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback 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.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService @@ -23,13 +23,13 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -52,7 +52,7 @@ class EpisodeViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, @@ -126,7 +126,7 @@ class EpisodeViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) fetchAndSetItem() } @@ -134,7 +134,7 @@ class EpisodeViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) fetchAndSetItem() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 41d53fd5..e5bf4e8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -29,6 +29,7 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import java.time.LocalDateTime @Composable @@ -109,10 +110,15 @@ fun Program( overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + val seasonEpisodeLabel = + formatSeasonEpisode( + program.seasonEpisode?.season, + program.seasonEpisode?.episode, + ) val subtitle = - remember(program) { + remember(program, seasonEpisodeLabel) { listOfNotNull( - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + seasonEpisodeLabel, program.subtitle, ).joinToString(" - ").ifBlank { null } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index d413f190..5de558c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -178,6 +179,7 @@ fun DvrSchedule( .focusRequester(focusRequester), ) showDialog?.let { item -> + val context = LocalContext.current ProgramDialog( state = DataLoadingState.Success(item), canRecord = true, @@ -185,13 +187,18 @@ fun DvrSchedule( showDialog = null }, onWatch = { program -> - program.data.channelId?.let { + val channelId = program.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, onRecord = { _, _ -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 01279c97..470f33c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -15,16 +15,19 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LiveTvService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatSeasonEpisode 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.roundMinutes -import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -50,7 +53,6 @@ import org.jellyfin.sdk.model.api.ImageType 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.TimerInfoDto import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber @@ -80,6 +82,7 @@ class LiveTvViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val favoriteWatchManager: FavoriteWatchManager, + private val liveTvService: LiveTvService, ) : ViewModel() { private lateinit var channelsIdToIndex: Map<UUID, Int> private val mutex = Mutex() @@ -429,12 +432,10 @@ class LiveTvViewModel _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } viewModelScope.launchDefault { try { - val result = - api.liveTvApi - .getProgram(programId.toServerString()) - .content - .let { BaseItem(it) } + val result = liveTvService.fetchProgramForDialog(programId) _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Error fetching program $programId") _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } @@ -446,16 +447,19 @@ class LiveTvViewModel series: Boolean, timerId: String?, ) { - if (timerId != null) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - if (series) { - api.liveTvApi.cancelSeriesTimer(timerId) - } else { - api.liveTvApi.cancelTimer(timerId) - } - state.value.let { - refreshPrograms(it.channels, it.programs.range) + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + if (result) { + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -465,37 +469,16 @@ class LiveTvViewModel series: Boolean, ) { viewModelScope.launchIO { - val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) - if (series) { - api.liveTvApi.createSeriesTimer(d) - } else { - val payload = - TimerInfoDto( - id = d.id, - type = d.type, - serverId = d.serverId, - externalId = d.externalId, - channelId = d.channelId, - externalChannelId = d.externalChannelId, - channelName = d.channelName, - programId = d.programId, - externalProgramId = d.externalProgramId, - name = d.name, - overview = d.overview, - startDate = d.startDate, - endDate = d.endDate, - serviceName = d.serviceName, - priority = d.priority, - prePaddingSeconds = d.prePaddingSeconds, - postPaddingSeconds = d.postPaddingSeconds, - isPrePaddingRequired = d.isPrePaddingRequired, - isPostPaddingRequired = d.isPostPaddingRequired, - keepUntil = d.keepUntil, - ) - api.liveTvApi.createTimer(payload) - } - state.value.let { - refreshPrograms(it.channels, it.programs.range) + try { + liveTvService.record(programId, series) + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -643,20 +626,18 @@ data class TvProgram( append(time) if (!isFake) { + val resources = WholphinApplication.instance.resources dot() - duration - .roundMinutes - .toString() - .let(::append) + append(resources.formatDuration(duration.roundMinutes)) if (now.isAfter(start) && now.isBefore(end)) { dot() java.time.Duration .between(now, end) .toKotlinDuration() .roundMinutes - .let { append("$it left") } + .let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } } - seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + formatSeasonEpisode(seasonEpisode?.season, seasonEpisode?.episode)?.let { dot() append(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt index a0971f8e..320ef649 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt @@ -4,8 +4,11 @@ import android.view.Gravity import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -54,40 +58,47 @@ fun LiveTvViewOptionsDialog( window.setGravity(Gravity.END) window.setDimAmount(0f) } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), + Column( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier .width(256.dp) .heightIn(max = 380.dp) - .focusRequester(focusRequester) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), shape = RoundedCornerShape(8.dp), ), ) { - stickyHeader { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - items(liveTvPreferences) { pref -> - pref as AppPreference<AppPreferences, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(preferences, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - ) + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(liveTvPreferences) { pref -> + pref as AppPreference<AppPreferences, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(preferences, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt index 53dabb47..04daa260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/ProgramDialog.kt @@ -159,7 +159,6 @@ fun ProgramDialog( Icon( imageVector = Icons.Default.PlayArrow, contentDescription = stringResource(R.string.delete), - tint = Color.Green.copy(alpha = .75f), ) Text( text = stringResource(R.string.watch_live), 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 54e32aa9..aafaac87 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 @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -30,7 +29,6 @@ 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.onFocusChanged import androidx.compose.ui.graphics.Color @@ -43,8 +41,8 @@ import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.LiveTvPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams @@ -57,14 +55,15 @@ import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import eu.wewox.programguide.ProgramGuide import eu.wewox.programguide.ProgramGuideDimensions import eu.wewox.programguide.ProgramGuideItem import eu.wewox.programguide.rememberSaveableProgramGuideState -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -75,14 +74,14 @@ import kotlin.math.abs @Composable fun TvGuideGrid( + preferences: UserPreferences, onRowPosition: (Int) -> Unit, modifier: Modifier = Modifier, viewModel: LiveTvViewModel = hiltViewModel(), ) { val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() - val preferences by viewModel.preferences.data - .collectAsState(AppPreferences.getDefaultInstance()) + val preferences by viewModel.preferences.data.collectAsState(preferences.appPreferences) val tvPrefs = preferences.interfacePreferences.liveTvPreferences var showViewOptions by remember { mutableStateOf(false) } var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) } @@ -196,11 +195,11 @@ fun TvGuideGrid( showItemDialog = index } }, + focusRequester = focusRequester, modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .focusRequester(focusRequester), + .background(MaterialTheme.colorScheme.background), ) } } @@ -212,13 +211,18 @@ fun TvGuideGrid( onDismissRequest = onDismissRequest, onWatch = { onDismissRequest.invoke() - it.data.channelId?.let { + val channelId = it.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, onRecord = { program, series -> @@ -260,7 +264,6 @@ fun TvGuideGrid( DialogItem( watchLiveStr, Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), dismissOnClick = true, ) { viewModel.navigationManager.navigateTo( @@ -308,6 +311,7 @@ fun TvGuideGridContent( onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, + focusRequester: FocusRequester, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -315,10 +319,12 @@ fun TvGuideGridContent( val scope = rememberCoroutineScope() val guideStart = remember(guideTimes) { guideTimes.first() } + var focusedChannelIndex by rememberInt(-1) var focusedItem by rememberPosition(RowColumn(0, 0)) - var focusedProgramIndex by remember { mutableIntStateOf(0) } + var focusedProgramIndex by rememberInt(0) + LaunchedEffect(onFocus, focusedProgramIndex) { - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { val program = programs.programs.getOrNull(focusedProgramIndex) if (program != null) { val channelIndex = channels.indexOfFirst { it.id == program.channelId } @@ -358,16 +364,13 @@ fun TvGuideGridContent( } } - Box(modifier = modifier) { + Box( + modifier = modifier, + ) { ProgramGuide( state = state, dimensions = tvGuideDimensions, - modifier = - Modifier - .fillMaxSize() - .focusProperties { - onEnter = { programFocusRequester.tryRequestFocus() } - }, + modifier = Modifier.fillMaxSize(), ) { guideStartHour = 0f currentTime( @@ -465,8 +468,12 @@ fun TvGuideGridContent( .ifElse( programIndex == focusedProgramIndex, Modifier.focusRequester(programFocusRequester), + ).ifElse( + focusedChannelIndex < 0 && programIndex == focusedProgramIndex, + Modifier.focusRequester(focusRequester), ).onFocusChanged { if (it.isFocused) { + focusedChannelIndex = -1 focusedProgramIndex = programIndex scope.launch { try { @@ -497,8 +504,12 @@ fun TvGuideGridContent( onLongClick = {}, modifier = Modifier - .onFocusChanged { + .ifElse( + focusedChannelIndex == channelIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { if (it.isFocused) { + focusedChannelIndex = channelIndex scope.launch { try { state.animateToChannel( 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 785ca74e..ef0d186e 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 @@ -33,10 +33,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ChapterRow @@ -54,6 +52,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.PersonContextActions import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog @@ -68,7 +67,7 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -107,10 +106,10 @@ fun MovieDetails( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( @@ -236,6 +235,7 @@ fun MovieDetails( }, canDelete = state.canDelete, onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, + onChooseVersion = { contextActions.onChooseVersion.invoke(movie, it) }, modifier = modifier, ) } @@ -272,6 +272,7 @@ fun MovieDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -304,6 +305,7 @@ fun MovieDetailsContent( onClickDiscover: (Int, DiscoverItem) -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -369,6 +371,14 @@ fun MovieDetailsContent( }, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(state.chosenStreams, movie, onChooseVersion) { + ChooseVersionParams( + chosenStreams = state.chosenStreams, + mediaSources = movie.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() @@ -400,7 +410,6 @@ fun MovieDetailsContent( item { ChapterRow( chapters = chapters, - aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE, onClick = { position = CHAPTER_ROW playOnClick.invoke(it.position) 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 9df28425..939d1331 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 @@ -17,16 +17,16 @@ 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 +import com.github.damontecres.wholphin.services.ServerReportService 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.ItemRowFields import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -62,7 +62,7 @@ class MovieViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, @@ -117,7 +117,7 @@ class MovieViewModel userPreferencesService.getCurrent(), ) val remoteTrailers = trailerService.getRemoteTrailers(movie) - val chapters = Chapter.fromDto(movie.data, api) + val chapters = Chapter.fromDto(movie.data) _state.update { it.copy( loading = DataLoadingState.Success(movie), @@ -168,7 +168,7 @@ class MovieViewModel GetSimilarItemsRequest( userId = serverRepository.currentUser?.id, itemId = itemId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 293807fb..f6a5d715 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -53,12 +53,12 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ExtrasRow @@ -86,12 +86,12 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -104,7 +104,6 @@ import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import java.util.UUID @@ -119,17 +118,21 @@ class AlbumViewModel navigationManager: NavigationManager, mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val imageUrlService: ImageUrlService, private val extrasService: ExtrasService, - @Assisted itemId: UUID, + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") private val initialSongId: UUID?, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): AlbumViewModel + fun create( + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") initialSongId: UUID?, + ): AlbumViewModel } private val _state = MutableStateFlow(AlbumState.EMPTY) @@ -159,6 +162,10 @@ class AlbumViewModel val songsDeferred = async { getPagerForAlbum(api, itemId) } val album = itemDeferred.await() val songs = songsDeferred.await() + val initialSongIndex = + initialSongId?.let { id -> + songs.indexOfBlocking { it?.id == id }.takeIf { it >= 0 } + } val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY) val allArtists = album.data.artists.orEmpty() + @@ -176,6 +183,7 @@ class AlbumViewModel isVariousArtists = isVariousArtists, imageUrl = imageUrl, songs = songs, + initialSongIndex = initialSongIndex, loading = LoadingState.Success, ) } @@ -203,7 +211,7 @@ class AlbumViewModel userId = serverRepository.currentUser?.id, albumIds = listOf(itemId), parentId = null, - fields = DefaultItemFields, + fields = ItemRowFields, recursive = true, includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), ) @@ -242,7 +250,7 @@ class AlbumViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) val album = api.userLibraryApi @@ -290,6 +298,7 @@ data class AlbumState( val songs: List<BaseItem?>, val similar: List<BaseItem>, val loading: LoadingState, + val initialSongIndex: Int? = null, val musicVideos: List<BaseItem?> = emptyList(), val extras: List<ExtrasItem> = emptyList(), val canDelete: Boolean = false, @@ -310,9 +319,10 @@ fun AlbumDetailsPage( itemId: UUID, preferences: UserPreferences, modifier: Modifier = Modifier, + initialSongId: UUID? = null, viewModel: AlbumViewModel = hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>( - creationCallback = { it.create(itemId) }, + creationCallback = { it.create(itemId, initialSongId) }, ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { @@ -321,7 +331,6 @@ fun AlbumDetailsPage( val state by viewModel.state.collectAsState() val currentMusic by viewModel.currentMusic.collectAsState() - var position by rememberPosition(0, 0) val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusManager = LocalFocusManager.current @@ -337,7 +346,7 @@ fun AlbumDetailsPage( onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.AUDIO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -357,12 +366,20 @@ fun AlbumDetailsPage( LoadingState.Success -> { val album = state.album!! + val itemsBefore = 2 + val initialSongIndex = state.initialSongIndex + var position by rememberPosition( + row = if (initialSongIndex != null) SONG_ROW else 0, + column = initialSongIndex ?: 0, + ) val firstFocusRequester = remember { FocusRequester() } val firstBringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } - val listState = rememberLazyListState() - val itemsBefore = 2 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialSongIndex?.let { itemsBefore + it } ?: 0, + ) val songFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -546,7 +563,7 @@ fun AlbumDetailsPage( item = item, onClick = onClick, onLongClick = onLongClick, - aspectRatio = AspectRatios.WIDE, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, played = item?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, favorite = item?.favorite ?: false, @@ -635,6 +652,7 @@ fun AlbumDetailsPage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index e87419aa..12238441 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -49,16 +49,17 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.ItemRowFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText @@ -81,12 +82,12 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -99,7 +100,6 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest @@ -115,7 +115,7 @@ class ArtistViewModel navigationManager: NavigationManager, mediaManagementService: MediaManagementService, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, @@ -155,8 +155,9 @@ class ArtistViewModel async { val request = GetItemsRequest( - parentId = itemId, - fields = DefaultItemFields, + albumArtistIds = listOf(itemId), + recursive = true, + fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), sortBy = listOf( @@ -167,14 +168,38 @@ class ArtistViewModel ) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() } + val appearsOnDeferred = + async { + val request = + GetItemsRequest( + contributingArtistIds = listOf(itemId), + recursive = true, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SORT_NAME, + ), + sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING), + ) + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + } val artist = itemDeferred.await() val albums = albumsDeferred.await() + val appearsOn = appearsOnDeferred.await() val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY) _state.update { it.copy( artist = artist, imageUrl = imageUrl, albums = albums, + appearsOnAlbums = appearsOn, loading = LoadingState.Success, ) } @@ -183,7 +208,7 @@ class ArtistViewModel val request = GetItemsRequest( artistIds = listOf(itemId), - fields = DefaultItemFields, + fields = SlimItemFields, recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), minCommunityRating = 1.0, @@ -219,24 +244,7 @@ class ArtistViewModel _state.update { it.copy(similar = similar) } } } - viewModelScope.launchIO { - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - artistIds = listOf(itemId), - parentId = null, - fields = DefaultItemFields, - recursive = true, - includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), - ) - val musicVideos = - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - if (musicVideos.isNotEmpty()) { - _state.update { - it.copy(musicVideos = musicVideos) - } - } - } + updateMusicVideos() } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } @@ -251,17 +259,52 @@ class ArtistViewModel } } + private fun updateMusicVideos() { + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + artistIds = listOf(itemId), + parentId = null, + fields = ItemRowFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } + } + fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - val artist = - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - _state.update { it.copy(artist = artist) } + // Refresh if artist, otherwise only could be a music video + if (itemId == this@ArtistViewModel.itemId) { + val artist = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(artist = artist) } + } else { + updateMusicVideos() + } + } + + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + // Only applies to music videos + updateMusicVideos() } } @@ -270,6 +313,7 @@ data class ArtistState( val imageUrl: String?, val topSongs: List<BaseItem?>, val albums: List<BaseItem?>, + val appearsOnAlbums: List<BaseItem?>, val similar: List<BaseItem>, val loading: LoadingState, val musicVideos: List<BaseItem?>, @@ -283,6 +327,7 @@ data class ArtistState( emptyList(), emptyList(), emptyList(), + emptyList(), LoadingState.Pending, emptyList(), false, @@ -293,7 +338,8 @@ data class ArtistState( private const val HEADER_ROW = 0 private const val SONG_ROW = HEADER_ROW + 1 private const val ALBUM_ROW = SONG_ROW + 1 -private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1 +private const val APPEARS_ON_ROW = SONG_ROW + 1 +private const val MUSIC_VIDEO_ROW = APPEARS_ON_ROW + 1 private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 @Composable @@ -328,7 +374,7 @@ fun ArtistDetailsPage( onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.AUDIO) + playlistViewModel.loadPlaylists() showPlaylistDialog.makePresent(itemId) }, onClickRemoveFromQueue = { _, _ -> }, @@ -500,41 +546,81 @@ fun ArtistDetailsPage( } } } - item { - ItemRow( - title = stringResource(R.string.albums), - items = state.albums, - onClickItem = { index, album -> - position = RowColumn(ALBUM_ROW, index) - viewModel.navigationManager.navigateTo(album.destination()) - }, - onLongClickItem = { index, album -> - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, + if (state.albums.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.albums), + items = state.albums, + onClickItem = { index, album -> + position = RowColumn(ALBUM_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), item = album, - index = index, - canDelete = - viewModel.canDelete( - album, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, ) - }, - cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - BannerCardWithTitle( - title = album?.name, - subtitle = album?.data?.productionYear?.toString(), - item = album, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = AspectRatios.SQUARE, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), - ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } + } + if (state.appearsOnAlbums.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.appears_on), + items = state.appearsOnAlbums, + onClickItem = { index, album -> + position = RowColumn(APPEARS_ON_ROW, index) + viewModel.navigationManager.navigateTo(album.destination()) + }, + onLongClickItem = { index, album -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = album, + index = index, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = album?.name, + subtitle = album?.data?.productionYear?.toString(), + item = album, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), + ) + } } if (state.musicVideos.isNotEmpty()) { item { @@ -546,7 +632,37 @@ fun ArtistDetailsPage( viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { index, item -> - // TODO + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = + ContextMenuActions( + navigateTo = viewModel.navigationManager::navigateTo, + onShowOverview = {}, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = viewModel::deleteItem, + onChooseVersion = { _, _ -> }, + onChooseTracks = { }, + onClearChosenStreams = {}, + ), + ) }, cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> BannerCardWithTitle( @@ -555,7 +671,7 @@ fun ArtistDetailsPage( item = item, onClick = onClick, onLongClick = onLongClick, - aspectRatio = AspectRatios.WIDE, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, played = item?.played ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, favorite = item?.favorite ?: false, @@ -631,6 +747,7 @@ fun ArtistDetailsPage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt index 6ccea46e..e60e2a08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -58,7 +58,7 @@ fun LyricsContent( LaunchedEffect(currentLyricPosition) { if (currentLyricPosition != null) { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { - if (currentLyricPosition !in listState.firstVisibleItemIndex..it) { + if (currentLyricPosition >= 0 && currentLyricPosition !in listState.firstVisibleItemIndex..it) { listState.animateScrollToItem(currentLyricPosition) } } @@ -121,7 +121,10 @@ fun LyricsContent( ), modifier = Modifier - .focusRequester(focusRequesters[index]), + .focusRequester(focusRequesters[index]) + .onFocusChanged { + if (it.isFocused) onFocusLyrics.invoke(true) + }, ) { val text = remember(lyric.text) { lyric.text.ifBlank { " " } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt index b93483c9..96e82032 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -31,6 +31,7 @@ fun MusicExpandableButtons( canDelete: Boolean, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + showShuffle: Boolean = true, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -53,13 +54,15 @@ fun MusicExpandableButtons( .onFocusChanged(buttonOnFocusChanged), ) } - item("shuffle") { - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { actions.onClickPlay.invoke(true) }, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) + if (showShuffle) { + item("shuffle") { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { actions.onClickPlay.invoke(true) }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } } item("instant_mix") { ExpandableFaButton( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt index 812927f1..87062cf2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt @@ -147,3 +147,11 @@ fun ViewModel.addToQueue( } } } + +suspend fun addToQueue( + api: ApiClient, + musicService: MusicService, + item: BaseItem, + index: Int, +) { +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt index db5bbf4c..f8dc0ccd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons -import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackFaButton import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource import com.github.damontecres.wholphin.ui.theme.WholphinTheme diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt index a340f66c..8d12bb33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -22,6 +22,8 @@ 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.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -48,6 +50,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.AudioItem +import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.settings.MoveDirection @@ -65,7 +68,6 @@ fun NowPlayingOverlay( state: NowPlayingState, player: Player, current: AudioItem?, - queue: List<AudioItem>, controllerViewState: ControllerViewState, onClickSong: (Int, AudioItem) -> Unit, onLongClickSong: (Int, AudioItem) -> Unit, @@ -78,6 +80,7 @@ fun NowPlayingOverlay( ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val currentItemFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } var queueHasFocus by remember { mutableStateOf(false) } @@ -89,13 +92,16 @@ fun NowPlayingOverlay( }, animationSpec = tween(durationMillis = 500), ) - val listState = rememberLazyListState() + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = state.musicServiceState.currentIndex, + ) var showButtons by remember { mutableStateOf(true) } val firstFocusRequester = remember { FocusRequester() } BackHandler(!showButtons) { scope.launch { - listState.animateScrollToItem(0) + listState.scrollToItem(0) firstFocusRequester.tryRequestFocus() } } @@ -141,6 +147,12 @@ fun NowPlayingOverlay( .align(Alignment.CenterHorizontally), ) } + val queue = + rememberQueue( + player, + state.musicServiceState.queueVersion, + state.musicServiceState.queueSize, + ) if (queue.isEmpty()) { Text("No items") } else { @@ -158,12 +170,16 @@ fun NowPlayingOverlay( .onFocusChanged { queueHasFocus = it.hasFocus }.focusProperties { + onEnter = { + currentItemFocusRequester.tryRequestFocus() + } onExit = { if (requestedFocusDirection == FocusDirection.Up) focusRequester.requestFocus() } }, ) { itemsIndexed(queue, key = { _, song -> song.key }) { index, song -> + val bringIntoViewRequester = remember { BringIntoViewRequester() } Row( verticalAlignment = Alignment.CenterVertically, modifier = @@ -176,7 +192,8 @@ fun NowPlayingOverlay( .onFocusChanged { if (it.hasFocus) showButtons = index < 3 controllerViewState.pulseControls() - }.animateItem(), + }.animateItem() + .bringIntoViewRequester(bringIntoViewRequester), ) { SongListItem( title = song.title, @@ -193,6 +210,9 @@ fun NowPlayingOverlay( .ifElse( index == 0, Modifier.focusRequester(firstFocusRequester), + ).ifElse( + index == state.musicServiceState.currentIndex, + Modifier.focusRequester(currentItemFocusRequester), ), ) Row( @@ -203,12 +223,22 @@ fun NowPlayingOverlay( MoveButton( icon = R.string.fa_caret_up, enabled = index > 0, - onClick = { onMoveQueue.invoke(index, MoveDirection.UP) }, + onClick = { + onMoveQueue.invoke(index, MoveDirection.UP) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, ) MoveButton( icon = R.string.fa_caret_down, enabled = index < queue.lastIndex, - onClick = { onMoveQueue.invoke(index, MoveDirection.DOWN) }, + onClick = { + onMoveQueue.invoke(index, MoveDirection.DOWN) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, ) Button( onClick = { onClickMoreItem.invoke(index, song) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 5b571e4a..e37c7ac1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -45,12 +45,11 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberCurrentMediaItemState import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -60,7 +59,6 @@ import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuDialog @@ -74,7 +72,9 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.delay import org.jellyfin.sdk.model.extensions.ticks +import java.util.Date import kotlin.time.Duration.Companion.seconds @OptIn(UnstableApi::class) @@ -89,13 +89,8 @@ fun NowPlayingPage( val context = LocalContext.current val state by viewModel.state.collectAsState() val player = viewModel.player - val queue = - rememberQueue( - player, - state.musicServiceState.queueVersion, - state.musicServiceState.queueSize, - ) - val current = queue.getOrNull(state.musicServiceState.currentIndex) + val currentMediaItem = rememberCurrentMediaItemState(player) + val current = (currentMediaItem.mediaItem?.localConfiguration?.tag as? AudioItem) val viz by viewModel.viz.collectAsState() val controllerViewState = viewModel.controllerViewState @@ -104,15 +99,14 @@ fun NowPlayingPage( .collectAsState( UserPreferences( AppPreferences.getDefaultInstance(), + null, ), ).value.appPreferences val musicPrefs = preferences.musicPreferences - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val keyHandler = - remember(isLtr, preferences) { + remember(preferences) { PlaybackKeyHandler( - isLtr = isLtr, player = player, controlsEnabled = true, skipWithLeftRight = false, @@ -122,6 +116,7 @@ fun NowPlayingPage( // seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, controllerViewState = controllerViewState, updateSkipIndicator = {}, + clearSkipIndicator = {}, skipBackOnResume = null, // skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, @@ -131,25 +126,31 @@ fun NowPlayingPage( }, onPlaybackDialogTypeClick = { }, getDurationMs = { player.duration }, + dpadSeekMode = preferences.playbackPreferences.dpadSeekMode, ) } var showViewOptionsDialog by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) } - var lyricsHaveFocus by remember { mutableStateOf(false) } + var lyricsFocused by remember { mutableStateOf<Date?>(null) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } val lyricsFocusRequester = remember { FocusRequester() } val hasLyrics = musicPrefs.showLyrics && state.hasLyrics - LaunchedEffect(lyricsHaveFocus) { - if (lyricsHaveFocus) { + LaunchedEffect(lyricsFocused) { + if (lyricsFocused != null) { controllerViewState.hideControls() + delay(5.seconds) + if (!controllerViewState.controlsVisible) { + focusRequester.tryRequestFocus() + } + lyricsFocused = null } } - BackHandler(lyricsHaveFocus) { + BackHandler(lyricsFocused != null) { focusRequester.tryRequestFocus() } @@ -287,8 +288,10 @@ fun NowPlayingPage( LyricsContent( lyrics = state.lyrics, currentLyricPosition = state.currentLyricIndex, - lyricsHaveFocus = lyricsHaveFocus, - onFocusLyrics = { lyricsHaveFocus = it }, + lyricsHaveFocus = lyricsFocused != null, + onFocusLyrics = { + lyricsFocused = Date() + }, onClick = { it.start ?.ticks @@ -341,7 +344,6 @@ fun NowPlayingPage( state = state, player = player, current = current, - queue = queue, controllerViewState = controllerViewState, onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, onClickMore = { showViewOptionsDialog = true }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt index f6fe40e4..cfaa1cfc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.services.BackdropResult import com.github.damontecres.wholphin.services.MusicServiceState import org.jellyfin.sdk.model.api.LyricDto @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.LyricDto @Stable data class NowPlayingState( val musicServiceState: MusicServiceState, + val currentMediaItem: AudioItem? = null, val lyrics: LyricDto? = null, val currentLyricIndex: Int? = null, val visualizerPermissions: Boolean = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt index f1cc9225..7e4b7025 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -12,7 +12,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.model.AudioItem -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.updateMusicPreferences @@ -23,40 +22,43 @@ import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NowPlayingStatus import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.combinePair import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.MoveDirection import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.mayakapps.kache.InMemoryKache 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.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.lyricsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.minutes @UnstableApi @HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) @@ -95,10 +97,13 @@ class NowPlayingViewModel private val lyricCache = InMemoryKache<UUID, LyricDto>(20) { - creationScope = CoroutineScope(Dispatchers.IO) + creationScope = CoroutineScope(WholphinDispatchers.IO) } init { + state.update { + it.copy(currentMediaItem = player.currentMediaItem?.localConfiguration?.tag as? AudioItem) + } player.addListener(this) addCloseable { player.removeListener(this) @@ -126,62 +131,39 @@ class NowPlayingViewModel }.join() controllerViewState.pulseControls() } - viewModelScope.launchDefault { - backdropService.clearBackdrop() - updateBackdrop(getCurrent()) - } - playbackLoop() + viewModelScope.launchDefault { backdropService.clearBackdrop() } + listenForBackdrop() + listenForLyrics() } fun reportInteraction() { controllerViewState.pulseControls() } - private suspend fun getCurrent(): AudioItem? { - val mediaItem = - onMain { - player.currentMediaItemIndex - .takeIf { it in 0..<player.mediaItemCount } - ?.let { player.getMediaItemAt(it) } - } - return mediaItem?.localConfiguration?.tag as? AudioItem - } - - private fun playbackLoop() { + private fun listenForLyrics() { viewModelScope.launchDefault { - while (isActive) { - val position = onMain { player.currentPosition }.milliseconds -// Timber.v("playbackLoop: %s", position) - getCurrent()?.let { audio -> -// Timber.v("Got current %s", audio.id) - if (audio.hasLyrics) { - val lyrics = - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - api.lyricsApi.getLyrics(audio.id).content - } + state.map { it.currentMediaItem }.distinctUntilChanged().collectLatest { audio -> + if (audio != null && audio.hasLyrics) { + val lyrics = fetchAndUpdateLyrics(audio) + Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) + while (lyrics != null && isActive) { + val position = onMain { player.currentPosition }.milliseconds + val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO + val lyricPosition = offset + position val lyricIndex = - if (lyrics != null) { - val offset = lyrics.metadata.offset?.ticks ?: Duration.ZERO - val lyricPosition = offset + position - lyrics.lyrics - .indexOfLast { - it.start?.ticks?.let { lyricPosition >= it } == true - }.takeIf { it >= 0 } - } else { - null - } -// Timber.v("lyricIndex=$lyricIndex") + lyrics.lyrics + .indexOfLast { + it.start?.ticks?.let { lyricPosition >= it } == true + }.takeIf { it >= 0 } state.update { it.copy( lyrics = lyrics, currentLyricIndex = lyricIndex, ) } + delay(150.milliseconds) } } - - delay(150) } } } @@ -192,100 +174,114 @@ class NowPlayingViewModel ) { val audio = mediaItem?.localConfiguration?.tag as? AudioItem Timber.v("onMediaItemTransition to %s", audio?.id) - updateBackdrop(audio) - viewModelScope.launchDefault { - state.update { - it.copy( - lyrics = null, - currentLyricIndex = null, - ) - } - audio?.let { audio -> - if (audio.hasLyrics) { - val lyrics = - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - api.lyricsApi.getLyrics(audio.id).content - } - Timber.d("Got lyrics for %s: %s", audio.id, lyrics != null) - state.update { - it.copy( - lyrics = lyrics, - ) - } - } - } + state.update { + it.copy( + currentMediaItem = audio, + lyrics = null, + currentLyricIndex = null, + ) } } - private var backDropJob: Job? = null - - private fun updateBackdrop(audio: AudioItem?) { - backDropJob?.cancel() - backDropJob = - viewModelScope.launchDefault { - val showBackdrop = - userPreferencesService - .getCurrent() - .appPreferences.musicPreferences.showBackdrop - if (showBackdrop) { - var backdropItem: BaseItem? = null + private suspend fun fetchAndUpdateLyrics(audio: AudioItem?): LyricDto? = + audio?.let { audio -> + if (audio.hasLyrics) { + lyricCache.getOrPut(audio.id) { + // TODO remote lyrics? try { - if (audio?.artistId != null) { - api.userLibraryApi.getItem(audio.artistId).content.let { - if (it.backdropImageTags?.isNotEmpty() == true) { - backdropItem = BaseItem(it, false) - } - } - } - if (backdropItem == null && audio?.albumId != null) { - api.userLibraryApi.getItem(audio.albumId).content.let { - backdropItem = - getBackdropItemForAlbum(api, BaseItem(it, false)) - } - } - if (backdropItem != null) { - doUpdateBackdrop(backdropItem) - } else { - doUpdateBackdropRandom() - } + api.lyricsApi.getLyrics(audio.id).content + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { - Timber.e(ex, "Error fetching backdrop") - doUpdateBackdropRandom() + Timber.e(ex, "Error fetching lyrics for %s", audio.id) + null } - delay(60.seconds) - doUpdateBackdropRandom() } + } else { + null } - } + } - private suspend fun doUpdateBackdropRandom() { - val randomArtist = - api.itemsApi - .getItems( - recursive = true, - imageTypes = listOf(ImageType.BACKDROP), - includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), - sortBy = listOf(ItemSortBy.RANDOM), - limit = 1, - ).content.items - .firstOrNull() - if (randomArtist != null) { - doUpdateBackdrop(BaseItem(randomArtist)) - } else { - clearBackdrop() + private fun listenForBackdrop() { + viewModelScope.launchDefault { + userPreferencesService.flow + .map { it.appPreferences.musicPreferences.showBackdrop } + .combinePair(state.map { it.currentMediaItem }) + .distinctUntilChanged() + .collectLatest { (showBackdrop, audio) -> + if (showBackdrop && audio != null) { + try { + val song = + api.userLibraryApi + .getItem(audio.id) + .content + val songBackdrops = song.backdropImageUrls() + + val artistIds = song.artistItems.orEmpty().map { it.id } + val artists = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + ids = artistIds, + imageTypes = listOf(ImageType.BACKDROP), + limit = 50, + enableImages = true, + enableImageTypes = listOf(ImageType.BACKDROP), + ), + ).content.items + val artistBackdrops = + artists.flatMap { artist -> artist.backdropImageUrls() } + + val list = songBackdrops.shuffled() + artistBackdrops.shuffled() + Timber.v("Got %s backdrops for %s", list.size, song.id) + if (list.isNotEmpty()) { + val urls = generateSequence { list }.flatten() + urls.forEach { + val (position, duration) = onMain { player.currentPosition to player.duration } + if (duration - position >= 15_000 || state.value.backdropResult == BackdropResult.NONE) { + // Only change if there's backdrop currently, or at 15 seconds left to prevent quick changes + doUpdateBackdrop(audio.id, it) + delay(2.minutes) + } + } + } else { + clearBackdrop() + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching backdrops for %s", audio.id) + clearBackdrop() + } + } else { + clearBackdrop() + } + } } } - private suspend fun doUpdateBackdrop(item: BaseItem) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + private fun BaseItemDto.backdropImageUrls() = + backdropImageTags?.sorted().orEmpty().mapIndexedNotNull { index, tag -> + imageUrlService.getItemImageUrl( + itemId = id, + imageType = ImageType.BACKDROP, + tag = tag, + imageIndex = index, + ) + } + + private suspend fun doUpdateBackdrop( + itemId: UUID, + imageUrl: String, + ) { val (primaryColor, secondaryColor, tertiaryColor) = backdropService.extractColorsFromBackdrop( imageUrl, ) val backdropResult = BackdropResult( - itemId = item.id.toString(), + itemId = itemId.toString(), imageUrl = imageUrl, primaryColor = primaryColor, secondaryColor = secondaryColor, @@ -343,19 +339,9 @@ class NowPlayingViewModel fun updatePreferences(prefs: AppPreferences) { viewModelScope.launchDefault { - var backdropChanged = false preferencesDataStore.updateData { - backdropChanged = - it.musicPreferences.showBackdrop != prefs.musicPreferences.showBackdrop prefs } - if (backdropChanged) { - if (prefs.musicPreferences.showBackdrop) { - updateBackdrop(getCurrent()) - } else { - clearBackdrop() - } - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt new file mode 100644 index 00000000..acc802a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt @@ -0,0 +1,270 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.MusicContextActions +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.ImageType +import java.util.UUID + +@HiltViewModel(assistedFactory = SongViewModel.Factory::class) +class SongViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): SongViewModel + } + + private val _state = MutableStateFlow(SongState.EMPTY) + val state: StateFlow<SongState> = _state + + init { + init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.song }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } + } + + override fun init() { + viewModelScope.launchIO { + try { + val song = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + val imageUrl = imageUrlService.getItemImageUrl(song, ImageType.PRIMARY) + _state.update { + it.copy( + song = song, + imageUrl = imageUrl, + loading = LoadingState.Success, + ) + } + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + // The toggle returns the updated user data, so no refetch of the song is needed + val userData = favoriteWatchManager.setFavorite(itemId, favorite) + _state.update { state -> + state.copy( + song = state.song?.let { it.copy(data = it.data.copy(userData = userData)) }, + ) + } + } + } + +data class SongState( + val song: BaseItem?, + val imageUrl: String?, + val loading: LoadingState, + val canDelete: Boolean = false, +) { + companion object { + val EMPTY = SongState(null, null, LoadingState.Pending) + } +} + +/** + * Details page for a song that has no album, and therefore no album page to open. + */ +@Composable +fun SongDetailsPage( + itemId: UUID, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SongViewModel = + hiltViewModel<SongViewModel, SongViewModel.Factory>( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + val moreDialogActions = + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { _, song -> viewModel.play(song) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { id -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(id) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val song = state.song!! + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val buttonsFocusRequester = remember { FocusRequester() } + RequestOrRestoreFocus(buttonsFocusRequester) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 32.dp), + ) { + AlbumHeader( + album = song, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + title = song.title ?: "", + actions = + remember(song) { + MusicButtonActions( + onClickPlay = { shuffled -> + viewModel.play(song, shuffled = shuffled) + }, + onClickInstantMix = { viewModel.startInstantMix(song.id) }, + onClickFavorite = { + viewModel.setFavorite(song.id, !song.favorite) + }, + onClickMore = { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = song, + index = 0, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + onConfirmDelete = { viewModel.deleteItem(song) }, + ) + }, + favorite = song.favorite, + canDelete = state.canDelete, + showShuffle = false, + buttonOnFocusChanged = {}, + modifier = Modifier.focusRequester(buttonsFocusRequester), + ) + } + } + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { songId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, songId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, songId) + showPlaylistDialog.makeAbsent() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 4e5fa329..e4664ac9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -20,11 +20,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.DenseListItem import androidx.tv.material3.Icon -import androidx.tv.material3.ListItem import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.LocalContentColor import androidx.tv.material3.Text @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -53,7 +54,12 @@ fun SongListItem( isQueued: Boolean = false, ) = SongListItem( title = song?.title, - artist = if (showArtist) song?.data?.albumArtist else null, + artist = + if (showArtist) { + remember(song) { song?.data?.artists?.joinToString(", ") } + } else { + null + }, indexNumber = song?.data?.indexNumber, runtime = remember(song) { @@ -116,7 +122,7 @@ fun SongListItem( } val trailingContent = @Composable { Text( - text = runtime.toString(), + text = runtime?.let { LocalResources.current.formatDuration(it) } ?: "", ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt index 8e170706..26346e7c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.DetailItemFields import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.api.client.ApiClient @@ -19,7 +19,7 @@ suspend fun ViewModel.getPagerForAlbum( GetItemsRequest( parentId = albumId, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, sortBy = listOf( ItemSortBy.PARENT_INDEX_NUMBER, @@ -39,7 +39,7 @@ suspend fun ViewModel.getPagerForArtist( artistIds = listOf(artistId), recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, // TODO better sort sortBy = listOf( @@ -60,7 +60,7 @@ suspend fun ViewModel.getPagerForPlaylist( parentId = playlistId, recursive = true, includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, + fields = DetailItemFields, sortBy = listOf( ItemSortBy.DEFAULT, 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 83987524..600dee28 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 @@ -13,6 +13,8 @@ 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.components.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -27,6 +29,7 @@ fun FocusedEpisodeFooter( favoriteOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -52,6 +55,14 @@ fun FocusedEpisodeFooter( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier.fillMaxWidth(), ) } 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 7c437f21..f42961ad 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 @@ -88,7 +88,6 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID import kotlin.time.Duration @@ -133,10 +132,10 @@ fun SeriesDetails( viewModel.setFavorite(itemId, favorite, null) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = Optional.present(itemId) }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( @@ -307,6 +306,7 @@ fun SeriesDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -742,7 +742,6 @@ fun buildDialogForSeason( DialogItem( resources.getString(R.string.play), Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, 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 7532be7a..ccc8f493 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 @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -93,7 +92,7 @@ fun SeriesOverview( LaunchedEffect(Unit) { if (state.seasons.isNotEmpty()) { state.seasons.getOrNull(position.seasonTabIndex)?.let { - viewModel.loadEpisodes(it.id) + viewModel.loadEpisodes(it.id, it.indexNumber) } } } @@ -116,10 +115,10 @@ fun SeriesOverview( viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex) }, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = viewModel::deleteItem, onChooseVersion = { item, source -> viewModel.savePlayVersion( @@ -211,7 +210,7 @@ fun SeriesOverview( onChangeSeason = { index -> if (index != position.seasonTabIndex) { state.seasons.getOrNull(index)?.let { season -> - viewModel.loadEpisodes(season.id) + viewModel.loadEpisodes(season.id, season.indexNumber) viewModel.position.update { SeriesOverviewPosition(index, 0) } @@ -311,6 +310,7 @@ fun SeriesOverview( }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, onConfirmDelete = viewModel::deleteItem, + onChooseVersion = contextActions.onChooseVersion, modifier = modifier, ) } @@ -347,6 +347,7 @@ fun SeriesOverview( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } 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 999ed7c4..5fee7b24 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 @@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams @@ -57,15 +56,19 @@ import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PersonKind import kotlin.time.Duration @@ -97,6 +100,7 @@ fun SeriesOverviewContent( canDelete: (BaseItem) -> Boolean, onConfirmDelete: (BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -117,14 +121,21 @@ fun SeriesOverviewContent( val scrollConnection = rememberDelayedNestedScroll() var requestFocusAfterSeason by remember { mutableStateOf(false) } - val seasonStr = stringResource(R.string.tv_season) + val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } + val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val tabs = - seasons.map { season -> - season?.name - ?: season?.data?.indexNumber?.let { "$seasonStr $it" } - ?: "" + seasons.mapIndexed { index, season -> + TabDetails( + title = + StringStringProvider( + season?.name + ?: season?.data?.indexNumber?.let { formatSeasonNumber(it) } + ?: "", + ), + tabFocusRequester = tabFocusRequesters[index], + contentFocusRequester = contentFocusRequesters[index], + ) } - val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val currentOnChangeSeason by rememberUpdatedState(onChangeSeason) @@ -170,7 +181,6 @@ fun SeriesOverviewContent( requestFocusAfterSeason = true } }, - focusRequesters = focusRequesters, modifier = Modifier .focusRequester(tabRowFocusRequester) @@ -323,6 +333,7 @@ fun SeriesOverviewContent( }, canDelete = canDelete.invoke(ep), onConfirmDelete = { onConfirmDelete.invoke(ep) }, + onChooseVersion = { onChooseVersion.invoke(ep, it) }, modifier = Modifier .padding(top = 4.dp) 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 bdce9a20..cb2146df 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 @@ -18,16 +18,16 @@ 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 +import com.github.damontecres.wholphin.services.ServerReportService 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.ItemRowFields import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault @@ -37,10 +37,12 @@ import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder import dagger.assisted.Assisted @@ -48,9 +50,9 @@ 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.CompletableDeferred import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -94,7 +96,7 @@ class SeriesViewModel private val trailerService: TrailerService, private val extrasService: ExtrasService, val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, @@ -137,10 +139,11 @@ class SeriesViewModel val episodeListDeferred = if (seriesPageType == SeriesPageType.OVERVIEW) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { if (seasonEpisodeIds != null) { loadEpisodesInternal( seasonEpisodeIds.seasonId, + seasonEpisodeIds.seasonNumber, seasonEpisodeIds.episodeId, seasonEpisodeIds.episodeNumber, ) @@ -148,6 +151,7 @@ class SeriesViewModel seasonsDeferred.await().firstOrNull()?.let { loadEpisodesInternal( it.id, + it.indexNumber, null, null, ) @@ -157,18 +161,30 @@ class SeriesViewModel } else { CompletableDeferred(value = EpisodeList.Loading) } - val seasons = seasonsDeferred.await() - val episodes = episodeListDeferred.await() + val (seasons, episodes) = + try { + val seasons = seasonsDeferred.await() + val episodes = episodeListDeferred.await() + seasons to episodes + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching seasons/episodes for series %s", seriesId) + _state.update { it.copy(series = DataLoadingState.Error(ex)) } + return@launchIO + } Timber.v("Done") if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) { viewModelScope.launchIO { val index = (seasons as? ApiRequestPager<*>)?.let { - findIndexOf( - seasonEpisodeIds.seasonNumber, - seasonEpisodeIds.seasonId, - it, + findIndexByNumberOrIdFast( + targetNum = seasonEpisodeIds.seasonNumber, + targetId = seasonEpisodeIds.seasonId, + list = it, + parentId = null, + parentIndex = null, ) } ?: 0 Timber.v("Got initial season index: $index") @@ -219,7 +235,7 @@ class SeriesViewModel GetSimilarItemsRequest( userId = serverRepository.currentUser?.id, itemId = seriesId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items @@ -292,7 +308,7 @@ class SeriesViewModel series: BaseItem, seasonNum: Int?, ): Deferred<List<BaseItem?>> = - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { Timber.v("getSeasons for %s", series.id) val request = GetItemsRequest( @@ -301,6 +317,7 @@ class SeriesViewModel includeItemTypes = listOf(BaseItemKind.SEASON), sortBy = listOf(ItemSortBy.INDEX_NUMBER), sortOrder = listOf(SortOrder.ASCENDING), + enableUserData = seriesPageType == SeriesPageType.DETAILS, fields = if (seriesPageType == SeriesPageType.DETAILS) { listOf( @@ -319,21 +336,14 @@ class SeriesViewModel request, GetItemsRequestHandler, viewModelScope, - pageSize = 10, + pageSize = 20, ).init(seasonNum ?: 0) -// val seasons = -// GetItemsRequestHandler.execute(api, request).content.items.map { -// BaseItem.from( -// it, -// api, -// ) -// } -// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") pager } private suspend fun loadEpisodesInternal( seasonId: UUID, + seasonNum: Int?, episodeId: UUID?, episodeNumber: Int?, ): EpisodeList { @@ -350,6 +360,7 @@ class SeriesViewModel ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CAN_DELETE, + ItemFields.PARENT_ID, ), ) Timber.v( @@ -361,10 +372,15 @@ class SeriesViewModel pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - findIndexOf(episodeNumber, episodeId, pager) - .coerceAtLeast(0) + findIndexByNumberOrIdFast( + targetNum = episodeNumber, + targetId = episodeId, + list = pager, + parentId = seasonId, + parentIndex = seasonNum, + ).coerceAtLeast(0) } else { - // Force the first page to to be fetched + // Force the first page to be fetched if (pager.isNotEmpty()) { pager.getBlocking(0) } @@ -374,7 +390,10 @@ class SeriesViewModel return EpisodeList.Success(seasonId, pager, initialIndex) } - fun loadEpisodes(seasonId: UUID) { + fun loadEpisodes( + seasonId: UUID, + seasonNum: Int?, + ) { val currentEpisodes = (state.value.episodes as? EpisodeList.Success) if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { _state.update { @@ -388,7 +407,7 @@ class SeriesViewModel viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = try { - loadEpisodesInternal(seasonId, null, null) + loadEpisodesInternal(seasonId, seasonNum, null, null) } catch (e: Exception) { Timber.e(e, "Error loading episodes for $seriesId for season $seasonId") EpisodeList.Error(e) @@ -405,7 +424,7 @@ class SeriesViewModel itemId: UUID, played: Boolean, listIndex: Int?, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) listIndex?.let { refreshEpisode(itemId, listIndex) @@ -440,7 +459,7 @@ class SeriesViewModel itemId: UUID, favorite: Boolean, listIndex: Int?, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (listIndex != null) { refreshEpisode(itemId, listIndex) @@ -452,13 +471,13 @@ class SeriesViewModel fun setSeasonWatched( seasonId: UUID, played: Boolean, - ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) updateSeries() } fun setWatchedSeries(played: Boolean) = - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) updateSeries() } @@ -466,7 +485,7 @@ class SeriesViewModel fun refreshEpisode( itemId: UUID, listIndex: Int, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { val eps = state.value.episodes if (eps is EpisodeList.Success) { eps.episodes.refreshItem(listIndex, itemId) @@ -481,7 +500,7 @@ class SeriesViewModel * Play whichever episode is next up for series or else the first episode */ fun playNextUp() { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { val result by api.tvShowsApi.getNextUp(seriesId = seriesId) val nextUp = result.items.firstOrNull() ?: api.tvShowsApi @@ -491,7 +510,7 @@ class SeriesViewModel ).content.items .firstOrNull() if (nextUp != null) { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { navigateTo(Destination.Playback(BaseItem(nextUp))) } } else { @@ -582,7 +601,7 @@ class SeriesViewModel val result = peopleInEpisodeCache .get(item.id) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { val list = api.userLibraryApi .getItem(item.id) @@ -704,50 +723,158 @@ enum class SeriesPageType { OVERVIEW, } -private suspend fun findIndexOf( +private fun checkNumberOrId( targetNum: Int?, targetId: UUID?, - pager: ApiRequestPager<*>, + indexNumber: Int?, + id: UUID?, +): Boolean = equalsNotNull(targetId, id) || equalsNotNull(indexNumber, targetNum) + +private fun checkParent( + parentId: UUID?, + parentIndex: Int?, + item: BaseItem?, +) = if (parentId != null || parentIndex != null) { + equalsNotNull(parentId, item?.data?.parentId) || + equalsNotNull(parentIndex, item?.data?.parentIndexNumber) +} else { + true +} + +/** + * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] + * + * If the list is smaller than its page size, then the entire dataset is cached and + * [BlockingList.indexOfBlocking] is used, otherwise this function calls [findIndexByNumberOrId] + */ +suspend fun findIndexByNumberOrIdFast( + targetNum: Int?, + targetId: UUID?, + list: ApiRequestPager<*>, + parentId: UUID?, + parentIndex: Int?, +): Int = + if (list.size <= list.pageSize) { + Timber.v("Using findIndexByNumberOrIdFast indexOfBlocking method") + list.indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && + checkParent(parentId, parentIndex, it) + } + } else { + findIndexByNumberOrId( + targetNum, + targetId, + list as BlockingList<BaseItem?>, + parentId, + parentIndex, + ) + } + +/** + * Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId] + * + * This is necessary in cases where items are missing. E.g. if looking for episode 4 but + * episodes 2 & 3 is missing, then the index in the list for episode 4 will be `1`. + * + * @param targetNum the 1-index season or episode number + * @param targetId the season or episode ID + * + * @return the index within [list] that matches, or zero if no match is found + */ +suspend fun findIndexByNumberOrId( + targetNum: Int?, + targetId: UUID?, + list: BlockingList<BaseItem?>, + parentId: UUID? = null, + parentIndex: Int? = null, ): Int { + Timber.v("Using findIndexByNumberOrId") + // Adjust for 1-based numbers + val listIndex = targetNum?.minus(1)?.coerceAtLeast(0) val index = - if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { + if (targetId != null && (targetNum == null || listIndex !in list.indices)) { // No hint info, so have to check everything - pager.indexOfBlocking { - equalsNotNull(it?.indexNumber, targetNum) || - equalsNotNull(it?.id, targetId) - } - } else if (targetNum != null && targetNum in pager.indices) { - // Start searching from the season number and choose direction from there - val num = pager.getBlocking(targetNum)?.indexNumber - if (num.lt(targetNum)) { - for (i in targetNum + 1 until pager.lastIndex) { - val season = pager.getBlocking(i) - if (equalsNotNull(season?.indexNumber, targetNum) || - equalsNotNull(season?.id, targetId) - ) { - return i - } - } - return 0 - } else if (num.gt(targetNum)) { - for (i in targetNum - 1 downTo 0) { - val season = pager.getBlocking(i) - if (equalsNotNull(season?.indexNumber, targetNum) || - equalsNotNull(season?.id, targetId) - ) { - return i - } - } - return 0 - } else { - targetNum - } + list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) + }.coerceAtLeast(0) + } else if (listIndex != null && listIndex in list.indices) { + searchList(listIndex, targetNum, targetId, list, parentId, parentIndex) } else { 0 } return index } +private suspend fun searchList( + listIndex: Int, + targetNum: Int, + targetId: UUID?, + list: BlockingList<BaseItem?>, + parentId: UUID?, + parentIndex: Int?, +): Int { + var index = listIndex + var item = list.getBlocking(index) + if ((parentId != null || parentIndex != null) && !checkParent(parentId, parentIndex, item)) { + var leftIndex = listIndex - 1 + var rightIndex = listIndex + 1 + // Search before & after to find an episode with the right parent + while (leftIndex in list.indices || rightIndex in list.indices) { + if (leftIndex in list.indices) { + val left = list.getBlocking(leftIndex) + if (checkParent(parentId, parentIndex, left)) { + index = leftIndex + break + } + } + if (rightIndex in list.indices) { + val right = list.getBlocking(rightIndex) + if (checkParent(parentId, parentIndex, right)) { + index = rightIndex + break + } + } + leftIndex-- + rightIndex++ + } + // Search for the right episode starting from this new one + item = list.getBlocking(index) + } + val num = item?.indexNumber + if (num.lt(targetNum)) { + for (i in listIndex + 1 until list.size) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return i + } + } + return 0 + } else if (num.gt(targetNum)) { + for (i in listIndex - 1 downTo 0) { + val item = list.getBlocking(i) + if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return i + } + } + return 0 + } else if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) && + checkParent(parentId, parentIndex, item) + ) { + return index + } else { + return list + .indexOfBlocking { + checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) && + checkParent(parentId, parentIndex, item) + }.coerceAtLeast(0) + } +} + data class SeriesState( val series: DataLoadingState<BaseItem> = DataLoadingState.Pending, val seasons: List<BaseItem?> = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt new file mode 100644 index 00000000..b0d49985 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt @@ -0,0 +1,58 @@ +package com.github.damontecres.wholphin.ui.discover + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +sealed interface DiscoverFocusedItem { + val id: Int + val title: StringProvider? + val subtitle: String? + val overview: String? + val backDropUrl: String? + val logoUrl: String? + + data class Item( + val item: DiscoverItem, + ) : DiscoverFocusedItem { + override val id: Int + get() = item.id + override val title: StringProvider? = item.title?.let { StringStringProvider(it) } + override val subtitle: String? + get() = item.subtitle + override val overview: String? + get() = item.overview + override val backDropUrl: String? + get() = item.backDropUrl + override val logoUrl: String? + get() = item.logoUrl + } + + data class Genre( + override val id: Int, + val name: String, + val type: SeerrItemType, + override val backDropUrl: String?, + ) : DiscoverFocusedItem { + override val title: StringProvider = discoverGenreTitle(name, type) + override val subtitle: String? + get() = null + override val overview: String? + get() = null + override val logoUrl: String? + get() = null + } +} + +fun discoverGenreTitle( + name: String, + type: SeerrItemType, +): ResArgStringProvider = + when (type) { + SeerrItemType.MOVIE -> ResArgStringProvider(R.string.genre_movies, name) + SeerrItemType.TV -> ResArgStringProvider(R.string.genre_tv_shows, name) + else -> throw UnsupportedOperationException("$type not supported") + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt new file mode 100644 index 00000000..c61c0799 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.GenreCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.util.DataLoadingState + +@Composable +fun DiscoverGenreRow( + title: StringProvider, + items: DataLoadingState<List<Genre>>, + onClickItem: (Int, Genre) -> Unit, + onCardFocus: (Int) -> Unit, + modifier: Modifier = Modifier, + onLongClickItem: (Int, Genre) -> Unit = { _, _ -> }, +) { + when (items) { + is DataLoadingState.Error -> { + ErrorMessage(items.message, items.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = title.getString(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success<List<Genre>> -> { + ItemRow( + title = title.getString(), + items = items.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index, item, mod, onClick, onLongClick -> + GenreCard( + genre = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = + mod + .height(Cards.heightEpisode) + .onFocusChanged { + if (it.isFocused) onCardFocus.invoke(index) + }, + ) + }, + modifier = modifier, + horizontalPadding = 16.dp, + showViewMore = false, + viewMoreCardContent = {}, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt new file mode 100644 index 00000000..2064faa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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.runtime.Composable +import androidx.compose.runtime.remember +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.focus.focusRestorer +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.ui.components.DiscoverFilterByButton +import com.github.damontecres.wholphin.ui.components.DiscoverSortByButton +import com.github.damontecres.wholphin.ui.components.GridTitle + +@Composable +fun DiscoverGridHeader( + showHeader: Boolean, + showTitle: Boolean, + title: String, + sortAndDirection: DiscoverSortAndDirection, + onSortChange: (DiscoverSortAndDirection) -> Unit, + sortOptions: List<DiscoverSort>, + getPossibleFilterValues: suspend (DiscoverFilterBy<*>) -> List<FilterValueOption>, + onClickShowViewOptions: () -> Unit, + modifier: Modifier = Modifier, + currentFilter: DiscoverFilter = DiscoverFilter(), + filterOptions: List<DiscoverFilterBy<*>> = listOf(), + onFilterChange: (DiscoverFilter) -> Unit = {}, + onShowFilterDropdown: ((Boolean) -> Unit)? = null, + filterButtonFocusRequester: FocusRequester = remember { FocusRequester() }, +) { + AnimatedVisibility( + showHeader, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showTitle) { + GridTitle(title) + } + val endPadding = + 16.dp // + if (sortAndDirection.sort == DiscoverSort.ALPHABETICAL) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .focusRestorer() + .fillMaxWidth(), + ) { + if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.focusRestorer(), + ) { + if (sortOptions.isNotEmpty()) { + DiscoverSortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (filterOptions.isNotEmpty()) { + DiscoverFilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier.focusRequester(filterButtonFocusRequester), + onShow = onShowFilterDropdown, + ) + } +// ExpandableFaButton( +// title = R.string.view_options, +// iconStringRes = R.string.fa_sliders, +// onClick = onClickShowViewOptions, +// modifier = Modifier, +// ) + } + } + } + } + } +} 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 eba02b62..6eff501e 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,111 +1,121 @@ package com.github.damontecres.wholphin.ui.discover -import androidx.compose.animation.AnimatedVisibility -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 import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.compose.ui.focus.focusRequester import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.util.DiscoverRequestType @Composable fun DiscoverPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } - val tabs = - listOf( - stringResource(R.string.discover), - stringResource(R.string.request), - stringResource(R.string.search), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } - - LaunchedEffect(selectedTabIndex) { - logTab("discover", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) - } - OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - Column( - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, + remember { + listOf( + TabDetails(R.string.discover), + TabDetails(R.string.request), + TabDetails(R.string.search), + TabDetails(R.string.movies_title), + TabDetails(R.string.tv_shows_title), ) } - when (selectedTabIndex) { + var showHeader by rememberSaveable { mutableStateOf(true) } + + TabbedPage( + itemId = NavDrawerItem.Discover.id, + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Discover 0 -> { SeerrDiscoverPage( preferences = preferences, + positionCallback = { showHeader = it.row < 1 }, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Requests 1 -> { SeerrRequestsPage( - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + positionCallback = { columns, index -> showHeader = index < columns }, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Search 2 -> { - LaunchedEffect(Unit) { - preferencesViewModel.backdropService.clearBackdrop() - } DiscoverSearchPage( preferences = preferences, - modifier = Modifier.fillMaxSize(), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // Movies + 3 -> { + DiscoverRequestGrid( + viewModelKey = "movies", + showTitle = false, + destination = + Destination.DiscoverMoreResult( + DiscoverRequestType.DISCOVER_MOVIES, + 0, + ), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // TV + 4 -> { + DiscoverRequestGrid( + viewModelKey = "tv", + showTitle = false, + destination = + Destination.DiscoverMoreResult( + DiscoverRequestType.DISCOVER_TV, + 0, + ), + positionCallback = { columns, index -> showHeader = index < columns }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt index aa463ad6..fe6dd475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -1,26 +1,40 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.filter.DiscoverFilter +import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy +import com.github.damontecres.wholphin.data.filter.DiscoverSort +import com.github.damontecres.wholphin.data.filter.DiscoverSortAndDirection +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.discoverMovieFilters +import com.github.damontecres.wholphin.data.filter.discoverTvFilters import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.launchDefault @@ -39,6 +53,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.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -52,14 +68,17 @@ class DiscoverRequestViewModel private val seerrService: SeerrService, val navigationManager: NavigationManager, private val api: SeerrApi, + private val filterOptionCache: FilterOptionCache, @Assisted val type: DiscoverRequestType, @Assisted startIndex: Int, + @Assisted initialFilter: DiscoverFilter, ) : ViewModel() { @AssistedFactory interface Factory { fun create( type: DiscoverRequestType, startIndex: Int, + initialFilter: DiscoverFilter, ): DiscoverRequestViewModel } @@ -68,113 +87,219 @@ class DiscoverRequestViewModel init { viewModelScope.launchDefault { - try { - val pager = - when (type) { - DiscoverRequestType.DISCOVER_TV -> { - DiscoverRequestPager( - api, - DiscoverTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.DISCOVER_MOVIES -> { - DiscoverRequestPager( - api, - DiscoverMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.TRENDING -> { - DiscoverRequestPager( - api, - TrendingRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_TV -> { - DiscoverRequestPager( - api, - UpcomingTvRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UPCOMING_MOVIES -> { - DiscoverRequestPager( - api, - UpcomingMovieRequestHandler, - seerrService::createDiscoverItem, - viewModelScope, - ) - } - - DiscoverRequestType.UNKNOWN -> { - throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") - } - }.init(startIndex) - _state.update { - it.copy( - loading = DataLoadingState.Success(pager), - ) + val (sortOptions, potentialFilterOptions) = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSort.entries.toList() to discoverTvFilters + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSort.entries.toList() to discoverMovieFilters + DiscoverRequestType.TRENDING -> emptyList<DiscoverSort>() to emptyList() + DiscoverRequestType.UPCOMING_TV -> emptyList<DiscoverSort>() to emptyList() + DiscoverRequestType.UPCOMING_MOVIES -> emptyList<DiscoverSort>() to emptyList() + DiscoverRequestType.UNKNOWN -> emptyList<DiscoverSort>() to emptyList() } - } catch (ex: Exception) { - Timber.e(ex, "Error initializing %s", type) - _state.update { - it.copy( - loading = DataLoadingState.Error(ex), - ) + val sortAndDirection = + when (type) { + DiscoverRequestType.DISCOVER_TV -> DiscoverSortAndDirection() + DiscoverRequestType.DISCOVER_MOVIES -> DiscoverSortAndDirection() + DiscoverRequestType.TRENDING -> DiscoverSortAndDirection() + DiscoverRequestType.UPCOMING_TV -> DiscoverSortAndDirection() + DiscoverRequestType.UPCOMING_MOVIES -> DiscoverSortAndDirection() + DiscoverRequestType.UNKNOWN -> DiscoverSortAndDirection() } + val filterOptions = + if (potentialFilterOptions.isNotEmpty()) { + // If the initial filter includes filtering (eg genres), then don't allow for changing it + potentialFilterOptions.filter { it.get(initialFilter) == null } + } else { + potentialFilterOptions + } + _state.update { + it.copy( + startIndex = startIndex, + sortOptions = sortOptions, + filterOptions = filterOptions, + sortAndDirection = sortAndDirection, + ) + } + fetchData(sortAndDirection, initialFilter, startIndex) + } + } + + private var fetchJob: Job? = null + + private suspend fun fetchData( + sortAndDirection: DiscoverSortAndDirection, + filter: DiscoverFilter, + startIndex: Int = 0, + ) { + fetchJob?.cancel() + try { + _state.update { + it.copy( + filter = filter, + sortAndDirection = sortAndDirection, + loading = DataLoadingState.Loading, + ) + } + val filter = filter.copy(sortBy = sortAndDirection) + fetchJob = + viewModelScope.launchDefault { + val pager = + when (type) { + DiscoverRequestType.DISCOVER_TV -> { + DiscoverRequestPager( + api, + DiscoverTvRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.DISCOVER_MOVIES -> { + DiscoverRequestPager( + api, + DiscoverMovieRequestHandler(filter), + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.TRENDING -> { + DiscoverRequestPager( + api, + TrendingRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_TV -> { + DiscoverRequestPager( + api, + UpcomingTvRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UPCOMING_MOVIES -> { + DiscoverRequestPager( + api, + UpcomingMovieRequestHandler, + seerrService::createDiscoverItem, + viewModelScope, + ) + } + + DiscoverRequestType.UNKNOWN -> { + throw IllegalArgumentException("Cannot display grid for DiscoverRequestType.UNKNOWN") + } + }.init(startIndex) + _state.update { + it.copy( + loading = DataLoadingState.Success(pager), + ) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error initializing %s", type) + _state.update { + it.copy( + loading = DataLoadingState.Error(ex), + ) } } } + + fun onSortChange(newSort: DiscoverSortAndDirection) { + viewModelScope.launchDefault { + _state.update { it.copy(startIndex = 0) } + fetchData(newSort, state.value.filter) + } + } + + fun onFilterChange(newFilter: DiscoverFilter) { + viewModelScope.launchDefault { + _state.update { it.copy(startIndex = 0) } + fetchData(state.value.sortAndDirection, newFilter) + } + } + + suspend fun getPossibleFilterValues(filter: DiscoverFilterBy<*>): List<FilterValueOption> = + filterOptionCache.getFilterOptionValues(null, filter) } data class DiscoverRequestState( val loading: DataLoadingState<List<DiscoverItem?>> = DataLoadingState.Pending, + val startIndex: Int = 0, + val sortOptions: List<DiscoverSort> = emptyList(), + val filterOptions: List<DiscoverFilterBy<*>> = emptyList(), + val filter: DiscoverFilter = DiscoverFilter(), + val sortAndDirection: DiscoverSortAndDirection = DiscoverSortAndDirection(), ) @Composable fun DiscoverRequestGrid( destination: Destination.DiscoverMoreResult, + showTitle: Boolean, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, + viewModelKey: String? = null, viewModel: DiscoverRequestViewModel = hiltViewModel<DiscoverRequestViewModel, DiscoverRequestViewModel.Factory>( - creationCallback = { it.create(destination.type, destination.startIndex) }, + key = viewModelKey, + creationCallback = { + it.create( + destination.type, + destination.startIndex, + destination.initialFilter, + ) + }, ), ) { val state by viewModel.state.collectAsState() - when (val s = state.loading) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(modifier) - } - is DataLoadingState.Error -> { - ErrorMessage(s, modifier) - } + var showHeader by rememberSaveable { mutableStateOf(state.startIndex < 6) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + DiscoverGridHeader( + showHeader = showHeader, + showTitle = showTitle, + title = + destination.titleOverride?.getString() + ?: stringResource(destination.type.stringRes), + sortAndDirection = state.sortAndDirection, + onSortChange = viewModel::onSortChange, + sortOptions = state.sortOptions, + getPossibleFilterValues = viewModel::getPossibleFilterValues, + onClickShowViewOptions = {}, + currentFilter = state.filter, + filterOptions = state.filterOptions, + onFilterChange = viewModel::onFilterChange, + modifier = Modifier.fillMaxWidth(), + ) + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier) + } - is DataLoadingState.Success<List<DiscoverItem?>> -> { - val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - Column( - modifier = modifier, - ) { - GridTitle(stringResource(destination.type.stringRes)) + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier) + } + is DataLoadingState.Success<List<DiscoverItem?>> -> { + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( - initialPosition = destination.startIndex, + initialPosition = state.startIndex, pager = s.data, onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + viewModel.navigationManager.navigateTo(item.destination) }, onLongClickItem = { index, item -> }, onClickPlay = { _, _ -> }, @@ -191,6 +316,12 @@ fun DiscoverRequestGrid( width = Dp.Unspecified, ) }, + columns = 6, + positionCallback = { columns, index -> + showHeader = index < columns + positionCallback.invoke(columns, index) + }, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index f0664d49..fccf32ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -50,12 +50,13 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.SearchCombinedResults -import com.github.damontecres.wholphin.ui.main.SearchResult import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.search.SearchCombinedResults +import com.github.damontecres.wholphin.ui.search.SearchResult import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -88,13 +89,20 @@ class DiscoverSearchViewModel } Timber.v("Starting seerr search") seerrResults.value = SearchResult.Searching - val results = - seerrService - .search(query) - .map { seerrService.createDiscoverItem(it) } - .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - Timber.v("Seerr search complete: %s results", results.size) - seerrResults.value = SearchResult.SuccessSeerr(results) + try { + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + Timber.v("Seerr search complete: %s results", results.size) + seerrResults.value = SearchResult.SuccessSeerr(results) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error during seerr search") + seerrResults.value = SearchResult.Error(ex) + } } } } @@ -102,6 +110,7 @@ class DiscoverSearchViewModel @Composable fun DiscoverSearchPage( preferences: UserPreferences, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, viewModel: DiscoverSearchViewModel = hiltViewModel(), ) { @@ -238,10 +247,14 @@ fun DiscoverSearchPage( result = seerrResults, focusRequester = gridFocusRequester, onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, onPlayItem = { _, _ -> }, onClickPosition = { position = it.column }, onClickDiscover = onClickDiscover, - positionCallback = { columns, index -> position = index }, + positionCallback = { columns, index -> + position = index + positionCallback.invoke(columns, index) + }, modifier = Modifier.fillMaxSize(), ) } 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 6751a154..cd7c6a27 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 @@ -6,12 +6,11 @@ import androidx.compose.foundation.gestures.LocalBringIntoViewSpec 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height 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 @@ -23,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -30,7 +30,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.QuickDetailsData @@ -39,7 +39,10 @@ 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.SeerrService +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader @@ -47,16 +50,20 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject @@ -80,7 +87,7 @@ class SeerrDiscoverViewModel this.copy( movies = DiscoverRowData( - ResStringProvider(R.string.movies), + ResStringProvider(R.string.movies_title), it, DiscoverRequestType.DISCOVER_MOVIES, ), @@ -90,7 +97,7 @@ class SeerrDiscoverViewModel this.copy( tv = DiscoverRowData( - ResStringProvider(R.string.tv_shows), + ResStringProvider(R.string.tv_shows_title), it, DiscoverRequestType.DISCOVER_TV, ), @@ -126,6 +133,83 @@ class SeerrDiscoverViewModel ), ) } + viewModelScope.launchIO { + val movieGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderMovieGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s movie genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting movie genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + movieGenres = movieGenres, + ) + } + } + + viewModelScope.launchIO { + val tvGenres = + try { + val result = seerrService.api.searchApi.discoverGenresliderTvGet() + val genres = + result + .filter { it.id != null && it.name != null } + .map { + val id = it.id!!.toUUID() + val imageUrl = + it.backdrops?.randomOrNull()?.let { path -> + seerrService.createImageUrl( + imageType = ImageType.BACKDROP, + path = path, + mediaInfo = null, + // Don't need high resolution + backdropWidth = 1280, + backdropHeight = 720, + ) + } + Genre( + id = id, + name = it.name!!, + imageUrl = imageUrl, + ) + } + Timber.v("Got %s tv genres", genres.size) + DataLoadingState.Success(genres) + } catch (ex: Exception) { + Timber.e(ex, "Error getting tv genres") + DataLoadingState.Error(ex) + } + state.update { + it.copy( + tvGenres = tvGenres, + ) + } + } } private fun fetchAndUpdateState( @@ -149,11 +233,13 @@ class SeerrDiscoverViewModel } } - fun updateBackdrop(item: DiscoverItem?) { + fun updateBackdrop(item: DiscoverFocusedItem?) { viewModelScope.launchIO { if (item != null) { backdropService.submit("discover_${item.id}", item.backDropUrl) - fetchRating(item) + if (item is DiscoverFocusedItem.Item) { + fetchRating(item.id, item.item.type) + } } else { backdropService.clearBackdrop() } @@ -167,25 +253,26 @@ class SeerrDiscoverViewModel .build<Int, DiscoverRating>() // TODO this is not very efficient - fun fetchRating(item: DiscoverItem) { + fun fetchRating( + id: Int, + type: SeerrItemType, + ) { viewModelScope.launchIO { - val cachedResult = ratingCache.getIfPresent(item.id) + val cachedResult = ratingCache.getIfPresent(id) if (cachedResult != null) { return@launchIO } val result = - try { - when (item.type) { + getDiscoverRating(id) { + when (type) { SeerrItemType.MOVIE -> { DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet( - movieId = item.id, - ), + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id), ) } SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id)) } SeerrItemType.PERSON -> { @@ -196,19 +283,9 @@ class SeerrDiscoverViewModel DiscoverRating(null, null) } } - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - Timber.w("No rating found for %s", item.id) - DiscoverRating(null, null) - } else { - Timber.e(ex, "Error getting rating for %s", item.id) - return@launchIO - } - } catch (ex: Exception) { - Timber.e(ex, "Error getting rating for %s", item.id) - return@launchIO - } - ratingCache.put(item.id, result) + } ?: DiscoverRating(null, null) + + ratingCache.put(id, result) rating.update { ratingCache.asMap().toMap() } @@ -216,6 +293,10 @@ class SeerrDiscoverViewModel } } +fun Int.toUUID() = UUID(0L, toLong()) + +fun UUID.toInt() = leastSignificantBits.toInt() + data class DiscoverRowData( val title: StringProvider, val items: DataLoadingState<List<DiscoverItem>>, @@ -237,26 +318,85 @@ data class DiscoverState( val trending: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, + val movieGenres: DataLoadingState<List<Genre>> = DataLoadingState.Pending, + val tvGenres: DataLoadingState<List<Genre>> = DataLoadingState.Pending, ) +private const val ROW_TRENDING = 0 +private const val ROW_MOVIES = ROW_TRENDING + 1 +private const val ROW_GENRES_MOVIE = ROW_MOVIES + 1 +private const val ROW_UPCOMING_MOVIES = ROW_GENRES_MOVIE + 1 +private const val ROW_TV = ROW_UPCOMING_MOVIES + 1 +private const val ROW_GENRES_TV = ROW_TV + 1 +private const val ROW_UPCOMING_TV = ROW_GENRES_TV + 1 + +private const val LAST_ROW = ROW_UPCOMING_TV + @OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( preferences: UserPreferences, + positionCallback: (RowColumn) -> Unit, modifier: Modifier = Modifier, viewModel: SeerrDiscoverViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - val rows = - listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) val ratingMap by viewModel.rating.collectAsState() - val focusRequesters = remember(rows) { List(rows.size) { FocusRequester() } } + val focusRequesters = remember { List(LAST_ROW + 1) { FocusRequester() } } var position by rememberPosition(0, -1) - val focusedItem = + val focusedItem: DiscoverFocusedItem? = remember(position) { position.let { - (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + val discoverRow = + when (position.row) { + ROW_TRENDING -> state.trending + ROW_MOVIES -> state.movies + ROW_UPCOMING_MOVIES -> state.upcomingMovies + ROW_TV -> state.tv + ROW_UPCOMING_TV -> state.upcomingTv + else -> null + } + if (discoverRow != null) { + (discoverRow.items as? DataLoadingState.Success) + ?.data + ?.getOrNull(it.column) + ?.let { + DiscoverFocusedItem.Item(it) + } + } else { + when (position.row) { + ROW_GENRES_MOVIE -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.MOVIE, + backDropUrl = it.imageUrl, + ) + } + } + + ROW_GENRES_TV -> { + state.movieGenres.successValue + ?.getOrNull(position.column) + ?.let { + DiscoverFocusedItem.Genre( + id = it.id.toInt(), + name = it.name, + type = SeerrItemType.TV, + backDropUrl = it.imageUrl, + ) + } + } + + else -> { + null + } + } + } } } LaunchedEffect(focusedItem) { @@ -276,37 +416,45 @@ fun SeerrDiscoverPage( ) { val details = remember(focusedItem, ratingMap) { - buildList { - focusedItem - ?.releaseDate - ?.year - ?.toString() - ?.let(::add) - }.let { - val rating = focusedItem?.id?.let { ratingMap[it] } - val str = - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) - QuickDetailsData(str) + (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> + buildList { + focusedItem.item + .releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + val rating = focusedItem.id.let { ratingMap[it] } + val str = + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + QuickDetailsData(str) + } } } HomePageHeader( - title = focusedItem?.title, + title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, - overviewTwoLines = true, + overviewTwoLines = false, quickDetails = details, timeRemaining = null, + endsAt = null, showLogo = preferences.appPreferences.interfacePreferences.showLogos, - logoImageUrl = null, // TODO + logoImageUrl = focusedItem?.logoUrl, modifier = Modifier - .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) - .fillMaxHeight(.25f), + .padding( + top = HeaderUtils.topPadding, + // Intentionally no bottom padding because of tabs + bottom = 0.dp, + start = HeaderUtils.startPadding, + ).height(HeaderUtils.height), ) + val density = LocalDensity.current val spaceAbovePx = with(density) { @@ -314,6 +462,43 @@ fun SeerrDiscoverPage( 50.dp.toPx() } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + + @Composable + fun discoverItemRow( + rowIndex: Int, + row: DiscoverRowData, + ) { + 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) + positionCallback.invoke(position) + }, + focusRequester = focusRequesters[rowIndex], + enableViewMore = row.type != DiscoverRequestType.UNKNOWN, + onClickViewMore = { + (row.items as? DataLoadingState.Success<List<DiscoverItem>>)?.data?.size?.let { + position = RowColumn(rowIndex, it) + positionCallback.invoke(position) + } + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult( + type = row.type, + ), + ) + }, + modifier = + Modifier + .fillMaxWidth(), + ) + } + } CompositionLocalProvider( LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { @@ -325,32 +510,89 @@ fun SeerrDiscoverPage( .focusRestorer() .fillMaxSize(), ) { - itemsIndexed(rows) { rowIndex, row -> + item { discoverItemRow(ROW_TRENDING, state.trending) } + + // Movies + item { discoverItemRow(ROW_MOVIES, state.movies) } + item { 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], - enableViewMore = row.type != DiscoverRequestType.UNKNOWN, - onClickViewMore = { - (row.items as? DataLoadingState.Success<List<DiscoverItem>>)?.data?.size?.let { - position = RowColumn(rowIndex, it) - } + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.movies_title), + ) + }, + items = state.movieGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_MOVIE, index) viewModel.navigationManager.navigateTo( - Destination.DiscoverMoreResult(row.type), + Destination.DiscoverMoreResult( + type = DiscoverRequestType.DISCOVER_MOVIES, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.MOVIE, + ), + startIndex = 0, + ), ) }, - modifier = - Modifier - .fillMaxWidth(), + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_MOVIE, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_MOVIE]), ) } } + item { discoverItemRow(ROW_UPCOMING_MOVIES, state.upcomingMovies) } + + // TV + item { discoverItemRow(ROW_TV, state.tv) } + item { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverGenreRow( + title = + remember { + ResProviderStringProvider( + R.string.genres_in, + ResStringProvider(R.string.tv_shows_title), + ) + }, + items = state.tvGenres, + onClickItem = { index, genre -> + position = RowColumn(ROW_GENRES_TV, index) + viewModel.navigationManager.navigateTo( + Destination.DiscoverMoreResult( + type = DiscoverRequestType.DISCOVER_TV, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.TV, + ), + startIndex = 0, + ), + ) + }, + onCardFocus = { index -> + position = RowColumn(ROW_GENRES_TV, index) + positionCallback.invoke(position) + }, + modifier = Modifier.focusRequester(focusRequesters[ROW_GENRES_TV]), + ) + } + } + item { discoverItemRow(ROW_UPCOMING_TV, state.upcomingTv) } } } } 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 d83f840b..52815683 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 @@ -35,8 +35,8 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -77,7 +77,7 @@ class SeerrRequestsViewModel val requests = mediaRequests.mapNotNull { request -> if (request.media?.tmdbId != null) { - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { val type = SeerrItemType.fromString(request.type) when (type) { @@ -148,10 +148,12 @@ data class RequestGridItem( @Composable fun SeerrRequestsPage( focusRequesterOnEmpty: FocusRequester?, + positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, viewModel: SeerrRequestsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + val columns = 6 when (val state = state.requests) { is DataLoadingState.Error -> { @@ -213,7 +215,8 @@ fun SeerrRequestsPage( modifier = mod, ) }, - columns = 6, + columns = columns, + positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), ) } 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 4aa52ade..c2822b4e 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 @@ -44,6 +44,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -88,8 +89,8 @@ 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 +import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -102,8 +103,9 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { + LifecycleStartEffect(Unit) { viewModel.init() + onStopOrDispose { } } val state by viewModel.state.collectAsState() val loading = state.loadingState @@ -174,10 +176,10 @@ fun HomePage( onClickWatch = viewModel::setWatched, onClickFavorite = viewModel::setFavorite, onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) + playlistViewModel.loadPlaylists() showPlaylistDialog = itemId }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, onDeleteItem = { viewModel.deleteItem(position, it) }, @@ -255,6 +257,7 @@ fun HomePage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -361,7 +364,7 @@ fun HomePageContent( ) { LazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), contentPadding = PaddingValues( bottom = Cards.height2x3, @@ -371,6 +374,10 @@ fun HomePageContent( .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> + val rowModifier = + Modifier + .animateItem(placementSpec = null) + .padding(bottom = 8.dp) CompositionLocalProvider( LocalBringIntoViewSpec provides defaultBringIntoViewSpec, ) { @@ -381,7 +388,7 @@ fun HomePageContent( FocusableItemRow( title = r.title.getString(), subtitle = stringResource(R.string.loading), - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } @@ -390,7 +397,7 @@ fun HomePageContent( title = r.title.getString(), subtitle = r.localizedMessage, isError = true, - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } @@ -422,11 +429,10 @@ fun HomePageContent( } }, modifier = - Modifier + rowModifier .fillMaxWidth() .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), + .focusRequester(rowFocusRequesters[rowIndex]), horizontalPadding = viewOptions.spacing.dp, cardContent = { index, item, cardModifier, onClick, onLongClick -> val onFocus = @@ -469,7 +475,7 @@ fun HomePageContent( .onKeyEvent { onKey(it) }, ) }, - showViewMore = showViewMore, + showViewMore = showViewMore && row.showViewMore, viewMoreCardContent = { mod -> HomePageViewMoreCard( isEpisode = row.items.last()?.type == BaseItemKind.EPISODE, @@ -502,7 +508,7 @@ fun HomePageContent( FocusableItemRow( title = r.title.getString(), subtitle = stringResource(R.string.no_results), - modifier = Modifier.animateItem(), + modifier = rowModifier, ) } } @@ -547,6 +553,7 @@ fun HomePageHeader( overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails, timeRemaining = item?.timeRemainingOrRuntime, + endsAt = item?.data?.endDate, showLogo = showLogo, logoImageUrl = rememberLogoUrl(item), modifier = modifier, @@ -561,6 +568,7 @@ fun HomePageHeader( overviewTwoLines: Boolean, quickDetails: QuickDetailsData?, timeRemaining: Duration?, + endsAt: DateTime?, showLogo: Boolean, logoImageUrl: String?, modifier: Modifier = Modifier, @@ -584,7 +592,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails, timeRemaining) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) val overviewModifier = Modifier .padding(0.dp) 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 5ce045fd..9e405196 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 @@ -14,12 +14,14 @@ import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService 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.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerReportService 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.collectLatestIn +import com.github.damontecres.wholphin.ui.combinePair import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -28,9 +30,11 @@ import com.github.damontecres.wholphin.ui.util.EmptyStringProvider import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow @@ -42,7 +46,9 @@ import kotlinx.coroutines.selects.select import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -54,7 +60,7 @@ class HomeViewModel @param:ApplicationContext private val context: Context, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val mediaReportService: MediaReportService, + val serverReportService: ServerReportService, private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, @@ -67,131 +73,204 @@ class HomeViewModel private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow<HomeState> = _state + private var dataLoadingJob: Job? = null + init { datePlayedService.invalidateAll() -// init() + serverRepository.currentUserDtoFlow + .combinePair(homeSettingsService.currentSettings) + .collectLatestIn(viewModelScope) { (userDto, settings) -> + Timber.v( + "Got new userDto & settings, userId=%s, settings?=%s", + userDto?.id, + settings != HomePageResolvedSettings.EMPTY, + ) + _state.update { HomeState.EMPTY } + dataLoadingJob?.cancel() + if (userDto == null) { + Timber.d("UserDto is null") + return@collectLatestIn + } + if (settings == HomePageResolvedSettings.EMPTY) { + Timber.d("Home settings are empty") + return@collectLatestIn + } + if (userDto.id != settings.userId) { + Timber.d("User IDs don't match: %s vs %s", userDto?.id, settings.userId) + return@collectLatestIn + } + dataLoadingJob = + viewModelScope.launchIO { + loadHomeRows(userDto, settings) + } + } } fun init() { viewModelScope.launchIO { Timber.d("init HomeViewModel") - try { - val preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - - serverRepository.currentUserDto?.let { userDto -> - val libraries = - navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + serverRepository.currentUserDto?.let { userDto -> + if (dataLoadingJob?.isActive == false) { 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=%s, state.loadingState=%s, %s rows", - refresh, - state.loadingState, - settings.rows.size, - ) - _state.update { - it.copy( - loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, - refreshState = LoadingState.Loading, - settings = settings, - homeRows = - if (refresh) { - it.homeRows - } else { - List(settings.rows.size) { - HomeRowLoadingState.Pending(EmptyStringProvider) - } - }, + if (userDto.id == settings.userId) { + dataLoadingJob = + viewModelScope.launchIO { + loadHomeRows(userDto, settings) + } + } else { + Timber.d( + "Init user IDs don't match: %s vs %s", + userDto.id, + settings.userId, ) } - - val semaphore = Semaphore(4) - - val deferred = - settings.rows - .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, - ) - } - } - } - } - - if (refresh) { - // Replace rows as they complete - val remaining = deferred.withIndex().toMutableList() - while (remaining.isNotEmpty()) { - val (rowIndex, rowData) = - select { - // "Return" the first remaining that is completed - remaining - .forEach { (rowIndex, deferred) -> - deferred.onAwait { rowIndex to it } - } - } - Timber.v("Got row data index=%s", rowIndex) - remaining.removeIf { it.index == rowIndex } - _state.update { state -> - val newRows = - state.homeRows.toMutableList().apply { - set(rowIndex, rowData) - } - state.copy( - homeRows = newRows, - ) - } - } - _state.update { - it.copy( - loadingState = LoadingState.Success, - refreshState = LoadingState.Success, - ) - } - } else { - val rows = deferred.awaitAll() - Timber.v("Got all rows") - _state.update { - it.copy( - loadingState = LoadingState.Success, - refreshState = LoadingState.Success, - homeRows = rows, - ) - } - } - Timber.d("Home page load complete") - } - } catch (ex: Exception) { - Timber.e(ex, "Exception during home page loading") - if (state.value.loadingState == LoadingState.Success) { - showToast(context, "Error refreshing home: ${ex.localizedMessage}") - _state.update { it.copy(refreshState = LoadingState.Error(ex)) } } else { - _state.update { - it.copy(loadingState = LoadingState.Error(ex)) + Timber.v("Data loading job is active") + } + } + } + } + + suspend fun loadHomeRows( + userDto: UserDto, + settings: HomePageResolvedSettings, + ) { + Timber.i("Starting loadHomeRows") + try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + + 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=%s, state.loadingState=%s, %s rows", + refresh, + state.loadingState, + settings.rows.size, + ) + _state.update { + it.copy( + loadingState = if (refresh) LoadingState.Success else LoadingState.Loading, + refreshState = LoadingState.Loading, + settings = settings, + homeRows = + if (refresh) { + it.homeRows + } else { + List(settings.rows.size) { + HomeRowLoadingState.Pending(EmptyStringProvider) + } + }, + ) + } + + val semaphore = Semaphore(4) + + val deferred = + settings.rows + .map { row -> + viewModelScope.async(WholphinDispatchers.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: InvalidStatusException) { + if (ex.status == 404) { + Timber.w(ex, "404 on row %s", row) + HomeRowLoadingState.Success( + row.title, + emptyList(), + ) + } else { + Timber.e( + ex, + "Error %s on row %s", + ex.status, + row, + ) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error( + row.title, + exception = ex, + ) + } + } + } } + + if (refresh) { + // Replace rows as they complete + val remaining = deferred.withIndex().toMutableList() + while (remaining.isNotEmpty()) { + val (rowIndex, rowData) = + select { + // "Return" the first remaining that is completed + remaining + .forEach { (rowIndex, deferred) -> + deferred.onAwait { rowIndex to it } + } + } + Timber.v("Got row data index=%s", rowIndex) + remaining.removeIf { it.index == rowIndex } + _state.update { state -> + val newRows = + state.homeRows.toMutableList().apply { + set(rowIndex, rowData) + } + state.copy( + homeRows = newRows, + ) + } + } + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + ) + } + } else { + val rows = deferred.awaitAll() + Timber.v("Got all rows") + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + homeRows = rows, + ) + } + } + Timber.d("Home page load complete") + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception during home page loading") + if (state.value.loadingState == LoadingState.Success) { + showToast(context, "Error refreshing home: ${ex.localizedMessage}") + _state.update { it.copy(refreshState = LoadingState.Error(ex)) } + } else { + _state.update { + it.copy(loadingState = LoadingState.Error(ex)) } } } @@ -200,9 +279,9 @@ class HomeViewModel fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { init() } } @@ -210,9 +289,9 @@ class HomeViewModel fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { init() } } @@ -278,7 +357,7 @@ data class HomeState( HomeState( LoadingState.Pending, LoadingState.Pending, - listOf(), + emptyList(), HomePageResolvedSettings.EMPTY, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt deleted file mode 100644 index 9eef0972..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ /dev/null @@ -1,1163 +0,0 @@ -package com.github.damontecres.wholphin.ui.main - -import android.view.Gravity -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.graphics.Color -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.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.datastore.core.DataStore -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleResumeEffect -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 androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.preferences.updateSearchPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrService -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.EpisodeCard -import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.cards.ItemRowTitle -import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.SearchEditTextBox -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.detail.GridItemDetails -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -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.preferences.SwitchColors -import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.SearchRelevance -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -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.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -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 SearchViewModel - @Inject - constructor( - val api: ApiClient, - val navigationManager: NavigationManager, - private val appPreferences: DataStore<AppPreferences>, - private val seerrService: SeerrService, - val voiceInputManager: VoiceInputManager, - val userPreferencesService: UserPreferencesService, - ) : ViewModel() { - val voiceState = voiceInputManager.state - val soundLevel = voiceInputManager.soundLevel - val partialResult = voiceInputManager.partialResult - val seerrActive = seerrService.active - - private val _state = MutableStateFlow(SearchState()) - val state: StateFlow<SearchState> = _state - - private var currentQuery: String? = null - private var combinedMode = false - - fun search( - query: String?, - combined: Boolean = false, - ) { - if (currentQuery == query && combinedMode == combined) { - return - } - currentQuery = query - combinedMode = combined - if (query.isNotNullOrBlank()) { - if (combined) { - _state.update { it.copy(combinedResults = SearchResult.Searching) } - searchCombined(query) - } else { - _state.update { - it.copy( - movies = SearchResult.Searching, - series = SearchResult.Searching, - episodes = SearchResult.Searching, - collections = SearchResult.Searching, - albums = SearchResult.Searching, - artists = SearchResult.Searching, - songs = SearchResult.Searching, - ) - } - searchInternal( - query, - BaseItemKind.MOVIE, - ) { result, state -> state.copy(movies = result) } - searchInternal( - query, - BaseItemKind.SERIES, - ) { result, state -> state.copy(series = result) } - searchInternal(query, BaseItemKind.EPISODE) { result, state -> - state.copy( - episodes = result, - ) - } - searchInternal(query, BaseItemKind.BOX_SET) { result, state -> - state.copy( - collections = result, - ) - } - searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state -> - state.copy( - albums = result, - ) - } - searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state -> - state.copy( - artists = result, - ) - } - searchInternal( - query, - BaseItemKind.AUDIO, - ) { result, state -> state.copy(songs = result) } - } - searchSeerr(query) - } else { - _state.update { - it.copy( - combinedResults = SearchResult.NoQuery, - movies = SearchResult.NoQuery, - series = SearchResult.NoQuery, - episodes = SearchResult.NoQuery, - collections = SearchResult.NoQuery, - albums = SearchResult.NoQuery, - artists = SearchResult.NoQuery, - songs = SearchResult.NoQuery, - ) - } - } - } - - private fun searchInternal( - query: String, - type: BaseItemKind, - update: (SearchResult, SearchState) -> SearchState, - ) { - viewModelScope.launchIO { - try { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(type), - fields = SlimItemFields, - limit = 50, - ) - val result = api.itemsApi.getItems(request).content - val items = - (result.items ?: emptyList()).map { - BaseItem.from(it, api, false) - } - val sorted = - items.sortedWith( - compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.name ?: "" }, - ) - _state.update { update.invoke(SearchResult.Success(sorted), it) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Exception searching for $type") - _state.update { update.invoke(SearchResult.Error(ex), it) } - } - } - } - - private fun searchCombined(query: String) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - try { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = - listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - BaseItemKind.BOX_SET, - ), - fields = SlimItemFields, - limit = 50, - ) - - val result = api.itemsApi.getItems(request).content - val items = - (result.items ?: emptyList()).map { - BaseItem.from(it, api, false) - } - val sorted = - items.sortedWith( - compareBy<BaseItem> { SearchRelevance.score(it, query) } - .thenBy { it.name ?: "" }, - ) - _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } - } catch (ex: Exception) { - Timber.e(ex, "Exception in combined search") - _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } - } - } - } - - fun setCombinedResults(enabled: Boolean) { - viewModelScope.launchIO { - appPreferences.updateData { - it.updateSearchPreferences { - combinedSearchResults = enabled - } - } - } - } - - fun setVoiceSearchButtonVisible(visible: Boolean) { - viewModelScope.launchIO { - appPreferences.updateData { - it.updateSearchPreferences { - showVoiceSearchButton = visible - } - } - } - } - - private fun searchSeerr(query: String) { - viewModelScope.launchIO { - if (seerrService.active.first()) { - _state.update { it.copy(seerrResults = SearchResult.Searching) } - val results = - seerrService - .search(query) - .map { seerrService.createDiscoverItem(it) } - .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } - } - } - } - - init { - addCloseable(voiceInputManager) - } - - fun getHints(query: String) { - // TODO -// api.searchApi.getSearchHints() - } - } - -sealed interface SearchResult { - data object NoQuery : SearchResult - - data object Searching : SearchResult - - data class Error( - val ex: Exception, - ) : SearchResult - - data class Success( - val items: List<BaseItem?>, - ) : SearchResult - - data class SuccessSeerr( - val items: List<DiscoverItem>, - ) : SearchResult -} - -data class SearchState( - val movies: SearchResult = SearchResult.NoQuery, - val series: SearchResult = SearchResult.NoQuery, - val episodes: SearchResult = SearchResult.NoQuery, - val collections: SearchResult = SearchResult.NoQuery, - val albums: SearchResult = SearchResult.NoQuery, - val artists: SearchResult = SearchResult.NoQuery, - val songs: SearchResult = SearchResult.NoQuery, - val seerrResults: SearchResult = SearchResult.NoQuery, - val combinedResults: SearchResult = SearchResult.NoQuery, -) - -private const val SEARCH_ROW = 0 -private const val TAB_ROW = SEARCH_ROW + 1 -private const val MOVIE_ROW = TAB_ROW + 1 -private const val SERIES_ROW = MOVIE_ROW + 1 -private const val EPISODE_ROW = SERIES_ROW + 1 -private const val COLLECTION_ROW = EPISODE_ROW + 1 -private const val ALBUM_ROW = COLLECTION_ROW + 1 -private const val ARTIST_ROW = ALBUM_ROW + 1 -private const val SONG_ROW = ARTIST_ROW + 1 -private const val SEERR_ROW = SONG_ROW + 1 - -private const val COMBINED_ROW = TAB_ROW + 1 - -/** Delay for focus to settle after voice search dialog dismisses. */ -private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L - -@Composable -fun SearchPage( - userPreferences: UserPreferences, - modifier: Modifier = Modifier, - viewModel: SearchViewModel = hiltViewModel(), -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val state by viewModel.state.collectAsState() - - // Start with current preferences, but collect updates when view options change - val prefs = - viewModel.userPreferencesService.flow - .collectAsState(userPreferences) - .value.appPreferences.interfacePreferences.searchPreferences - val combinedMode = prefs.combinedSearchResults - val voiceSearchButtonVisible = prefs.showVoiceSearchButton - -// val query = rememberTextFieldState() - var query by rememberSaveable { mutableStateOf("") } - val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - val tabFocusRequesters = remember { List(2) { FocusRequester() } } - - val seerrActive by viewModel.seerrActive.collectAsState(initial = false) - var selectedTab by rememberSaveable { mutableIntStateOf(0) } - var showViewOptions by rememberSaveable { mutableStateOf(false) } - - var position by rememberPosition(0, 0) - var searchClicked by rememberSaveable { mutableStateOf(false) } - var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } - - LifecycleResumeEffect(Unit) { - onPauseOrDispose { - viewModel.voiceInputManager.stopListening() - } - } - - fun triggerImmediateSearch(searchQuery: String) { - immediateSearchQuery = searchQuery - searchClicked = true - viewModel.search(searchQuery, combinedMode) - } - - LaunchedEffect(query, combinedMode) { - when { - immediateSearchQuery == query -> { - immediateSearchQuery = null - } - - else -> { - delay(750L) - viewModel.search(query, combinedMode) - } - } - } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - val onClickItem = { index: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - val onPlayItem = { _: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - } - - val onClickDiscover = { _: Int, item: DiscoverItem -> - val dest = - if (item.jellyfinItemId != null && item.type.baseItemKind != null) { - Destination.MediaItem( - itemId = item.jellyfinItemId, - type = item.type.baseItemKind, - ) - } else { - Destination.DiscoveredItem(item) - } - viewModel.navigationManager.navigateTo(dest) - } - - var showHeader by remember { mutableStateOf(true) } - val positionCallback = { columns: Int, index: Int -> - showHeader = index < columns - } - val showTabs = seerrActive && query.isNotBlank() && showHeader && combinedMode - val isLibraryTab = selectedTab == 0 - - LaunchedEffect(seerrActive, query) { - if (!seerrActive || query.isBlank()) { - selectedTab = 0 - } - } - - LaunchedEffect( - searchClicked, - state, - combinedMode, - selectedTab, - seerrActive, - ) { - if (!searchClicked) return@LaunchedEffect - - withContext(Dispatchers.IO) { - // Want to focus on the first successful row after all of the ones before it are finished searching - val results = - if (isLibraryTab) { - if (combinedMode) { - listOf(state.combinedResults) - } else { - listOf( - state.movies, - state.series, - state.episodes, - state.collections, - state.albums, - state.artists, - state.songs, - ) - } - } else { - listOf(state.seerrResults) - } - val firstSuccess = - results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } - if (firstSuccess >= 0) { - val anyBeforeSearching = - results.subList(0, firstSuccess).any { it is SearchResult.Searching } - if (!anyBeforeSearching) { - val targetRow = - if (isLibraryTab) { - if (combinedMode) { - COMBINED_ROW - } else { - MOVIE_ROW + firstSuccess - } - } else { - SEERR_ROW - } - position = RowColumn(targetRow, 0) - onMain { focusRequesters[targetRow].tryRequestFocus() } - } - } - } - } - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - 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(0.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .focusGroup() - .focusRestorer(textFieldFocusRequester) - .focusRequester(focusRequesters[SEARCH_ROW]), - ) { - AnimatedVisibility( - visible = voiceSearchButtonVisible, - enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), - exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End), - ) { - VoiceSearchButton( - onSpeechResult = { spokenText -> - query = spokenText - triggerImmediateSearch(spokenText) - }, - voiceInputManager = viewModel.voiceInputManager, - modifier = Modifier.padding(end = 12.dp), - ) - } - - 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 - } - }, - ) - - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = { showViewOptions = true }, - modifier = Modifier.padding(start = 12.dp), - ) - } - } - AnimatedVisibility( - visible = showTabs, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTab, - tabs = - listOf( - stringResource(R.string.library), - stringResource(R.string.discover), - ), - focusRequesters = tabFocusRequesters, - onClick = { - selectedTab = it - val row = - when (selectedTab) { - 0 -> COMBINED_ROW - else -> SEERR_ROW - } - position = RowColumn(row, 0) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp), - ) - } - Box( - modifier = Modifier.fillMaxSize(), - ) { - SideEffect { - Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) - } - when { - isLibraryTab && combinedMode -> { - SearchCombinedResults( - result = state.combinedResults, - focusRequester = focusRequesters[COMBINED_ROW], - onClickItem = onClickItem, - onPlayItem = onPlayItem, - onClickPosition = { position = it }, - onClickDiscover = onClickDiscover, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - ) - } - - !isLibraryTab && combinedMode -> { - SearchCombinedResults( - result = state.seerrResults, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = onClickItem, - onPlayItem = onPlayItem, - onClickPosition = { position = it }, - onClickDiscover = onClickDiscover, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - ) - } - - isLibraryTab -> { - LazyColumn( - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 44.dp, - ), - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.focusGroup(), - ) { - searchResultRow( - title = R.string.movies, - result = state.movies, - rowIndex = MOVIE_ROW, - position = position, - focusRequester = focusRequesters[MOVIE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.tv_shows, - result = state.series, - rowIndex = SERIES_ROW, - position = position, - focusRequester = focusRequesters[SERIES_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.episodes, - result = state.episodes, - rowIndex = EPISODE_ROW, - position = position, - focusRequester = focusRequesters[EPISODE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - EpisodeCard( - item = item, - onClick = { - position = RowColumn(EPISODE_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = 140.dp, - modifier = mod.padding(horizontal = 8.dp), - ) - }, - ) - searchResultRow( - title = R.string.collections, - result = state.collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.albums, - result = state.albums, - rowIndex = ALBUM_ROW, - position = position, - focusRequester = focusRequesters[ALBUM_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.artists, - result = state.artists, - rowIndex = ARTIST_ROW, - position = position, - focusRequester = focusRequesters[ARTIST_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ARTIST_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.songs, - result = state.songs, - rowIndex = SONG_ROW, - position = position, - focusRequester = focusRequesters[SONG_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(SONG_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.discover, - result = state.seerrResults, - rowIndex = SEERR_ROW, - position = position, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } - } - - if (showViewOptions) { - SearchViewOptionsDialog( - combinedResults = combinedMode, - onCombinedResultsChange = viewModel::setCombinedResults, - voiceSearchButtonVisible = voiceSearchButtonVisible, - onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, - onDismissRequest = { showViewOptions = false }, - ) - } -} - -@Composable -fun SearchViewOptionsDialog( - combinedResults: Boolean, - onCombinedResultsChange: (Boolean) -> Unit, - voiceSearchButtonVisible: Boolean, - onVoiceSearchButtonVisibleChange: (Boolean) -> Unit, - onDismissRequest: () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.setGravity(Gravity.CENTER) - - Box( - modifier = - Modifier - .width(400.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - RoundedCornerShape(28.dp), - ).padding(24.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - - ListItem( - selected = false, - headlineContent = { - Text(stringResource(R.string.combined_search_results)) - }, - supportingContent = { - Text( - if (combinedResults) { - stringResource(R.string.combined_search_results_on) - } else { - stringResource(R.string.combined_search_results_off) - }, - ) - }, - trailingContent = { - Switch( - checked = combinedResults, - onCheckedChange = onCombinedResultsChange, - colors = SwitchColors(), - ) - }, - onClick = { onCombinedResultsChange(!combinedResults) }, - modifier = Modifier.fillMaxWidth(), - ) - - ListItem( - selected = false, - headlineContent = { - Text(stringResource(R.string.show_voice_search_button)) - }, - supportingContent = { - Text( - if (voiceSearchButtonVisible) { - stringResource(R.string.visible_ui) - } else { - stringResource(R.string.hidden_ui) - }, - ) - }, - trailingContent = { - Switch( - checked = voiceSearchButtonVisible, - onCheckedChange = onVoiceSearchButtonVisibleChange, - colors = SwitchColors(), - ) - }, - onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -@Composable -fun SearchCombinedResults( - result: SearchResult, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onPlayItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - onClickDiscover: (Int, DiscoverItem) -> Unit, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - when (val r = result) { - SearchResult.NoQuery -> { - Unit - } - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.searching), - modifier = modifier.padding(16.dp), - ) - } - - is SearchResult.Error -> { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = r.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = modifier.padding(16.dp), - ) - } - - is SearchResult.Success -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.no_results), - modifier = modifier.padding(16.dp), - ) - } else { - SearchGrid( - items = r.items, - focusRequester = focusRequester, - onClickItem = onClickItem, - onPlayItem = onPlayItem, - onClickPosition = onClickPosition, - positionCallback = positionCallback, - cardContent = { details -> - GridCard( - item = details.item, - onClick = details.onClick, - onLongClick = details.onLongClick, - modifier = details.mod, - fillWidth = details.widthPx, - ) - }, - modifier = modifier, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.no_results), - modifier = modifier.padding(16.dp), - ) - } else { - SearchGrid( - items = r.items, - focusRequester = focusRequester, - onClickItem = onClickDiscover, - onPlayItem = { _, _ -> }, - onClickPosition = onClickPosition, - positionCallback = positionCallback, - cardContent = { details -> - DiscoverItemCard( - item = details.item, - onClick = details.onClick, - onLongClick = details.onLongClick, - modifier = details.mod, - ) - }, - modifier = modifier, - ) - } - } - } -} - -@Composable -private fun <T : CardGridItem> SearchGrid( - items: List<T?>, - focusRequester: FocusRequester, - onClickItem: (Int, T) -> Unit, - onPlayItem: (Int, T) -> Unit, - onClickPosition: (RowColumn) -> Unit, - cardContent: @Composable (GridItemDetails<T>) -> Unit, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - RequestOrRestoreFocus(focusRequester) - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = modifier, - ) { - ItemRowTitle(stringResource(R.string.results)) - - CardGrid( - pager = items, - onClickItem = { index, item -> - onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) - onClickItem.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - onClickPlay = { index, item -> - onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) - onPlayItem.invoke(index, item) - }, - letterPosition = { -1 }, - gridFocusRequester = focusRequester, - showJumpButtons = false, - showLetterButtons = false, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - cardContent = cardContent, - ) - } -} - -fun LazyListScope.searchResultRow( - @StringRes title: Int, - result: SearchResult, - rowIndex: Int, - position: RowColumn, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - modifier: Modifier = Modifier, - onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, - cardContent: @Composable ( - index: Int, - item: BaseItem?, - modifier: Modifier, - onClick: () -> Unit, - onLongClick: () -> Unit, - ) -> Unit = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - showImageOverlay = true, - modifier = mod, - ) - }, -) { - item { - when (val r = result) { - is SearchResult.Error -> { - SearchResultPlaceholder( - title = stringResource(title), - message = r.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = Modifier, - ) - } - - SearchResult.NoQuery -> { - // no-op - } - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.searching), - modifier = modifier, - ) - } - - is SearchResult.Success -> { - if (r.items.isNotEmpty()) { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = cardContent, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (r.items.isNotEmpty()) { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = { index, item -> - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClickDiscover?.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - ) - } - } - } - } -} - -@Composable -fun SearchResultPlaceholder( - title: String, - message: String, - modifier: Modifier = Modifier, - messageColor: Color = MaterialTheme.colorScheme.onBackground, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.padding(bottom = 32.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = message, - style = MaterialTheme.typography.titleMedium, - color = messageColor, - ) - } -} 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 index 8c603911..c72d69a8 100644 --- 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 @@ -1,12 +1,9 @@ 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 @@ -16,7 +13,6 @@ 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 @@ -36,9 +32,7 @@ fun HomeLibraryRowTypeList( 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), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() @@ -136,7 +130,7 @@ enum class LibraryRowType( GENRES(R.string.genres), STUDIOS(R.string.studios), TV_CHANNELS(R.string.channels), - TV_PROGRAMS(R.string.live_tv), + TV_PROGRAMS(R.string.watch_live), 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 index 5714723e..922ae3cc 100644 --- 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 @@ -1,10 +1,8 @@ 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 @@ -17,10 +15,12 @@ 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.data.model.HomeRowViewOptions.Companion.genreDefault 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.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType data class HomeRowPresets( @@ -61,6 +61,48 @@ data class HomeRowPresets( -> HomeRowViewOptions() } + fun getByItemType(type: BaseItemKind): HomeRowViewOptions = + when (type) { + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> movieLibrary + + BaseItemKind.SERIES, + BaseItemKind.SEASON, + -> tvLibrary + + BaseItemKind.EPISODE -> continueWatching + + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + -> videoLibrary + + BaseItemKind.PLAYLIST -> playlist + + BaseItemKind.PERSON -> movieLibrary + + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.AUDIO, + -> musicLibrary + + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_CHANNEL, + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> liveTv + + BaseItemKind.GENRE, + BaseItemKind.MUSIC_GENRE, + BaseItemKind.STUDIO, + -> genreDefault.copy(heightDp = genreSize) + + else -> movieLibrary + } + companion object { val WholphinDefault by lazy { HomeRowPresets( @@ -249,9 +291,8 @@ fun HomeRowPresetsContent( LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.display_presets)) - LazyColumn( + HomeSettingsLazyColumn( contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .fillMaxHeight() 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 index e9f7f6e2..48901b0f 100644 --- 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 @@ -69,11 +69,11 @@ fun HomeRowSettings( LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(title) - LazyColumn { + HomeSettingsLazyColumn { preferenceOptions.forEachIndexed { groupIndex, prefGroup -> if (preferenceOptions.size > 1) { item { - TitleText(stringResource(prefGroup.title)) + SubTitleText(stringResource(prefGroup.title)) } } itemsIndexed(prefGroup.preferences) { index, pref -> 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 index 97249aa4..439b2505 100644 --- 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 @@ -1,11 +1,8 @@ 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 @@ -15,7 +12,6 @@ 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 @@ -31,9 +27,7 @@ fun HomeSettingsAddRow( // LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } Column(modifier = modifier) { TitleText(stringResource(R.string.add_row)) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() 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 index 5bf3056d..96c7583c 100644 --- 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 @@ -1,10 +1,7 @@ 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 @@ -14,14 +11,15 @@ 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.formatTypeName import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun HomeSettingsFavoriteList( + favoriteTypeOptions: List<BaseItemKind>, onClick: (BaseItemKind) -> Unit, modifier: Modifier = Modifier, firstFocus: FocusRequester = remember { FocusRequester() }, @@ -31,18 +29,16 @@ fun HomeSettingsFavoriteList( TitleText( stringResource(R.string.add_row_for, stringResource(R.string.favorites)), ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() .focusRestorer(firstFocus), ) { - itemsIndexed(favoriteOptionsList) { index, type -> + itemsIndexed(favoriteTypeOptions) { index, type -> HomeSettingsListItem( selected = false, - headlineText = stringResource(favoriteOptions[type]!!), + headlineText = stringResource(formatTypeName(type)), onClick = { onClick.invoke(type) }, modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), ) @@ -50,17 +46,3 @@ fun HomeSettingsFavoriteList( } } } - -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, - BaseItemKind.MUSIC_ARTIST to R.string.artists, - BaseItemKind.MUSIC_ALBUM to R.string.albums, - ) -} -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 index cc463b35..894980c4 100644 --- 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 @@ -1,10 +1,8 @@ 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 @@ -18,7 +16,6 @@ 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 @@ -51,8 +48,7 @@ fun HomeSettingsGlobal( modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), + HomeSettingsLazyColumn( modifier = modifier .fillMaxHeight() 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 index 4f265dbb..ec35cdb9 100644 --- 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 @@ -1,7 +1,13 @@ package com.github.damontecres.wholphin.ui.main.settings import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Modifier @@ -57,3 +63,21 @@ fun HomeSettingsListItem( glow = glow, interactionSource = interactionSource, ) + +@Composable +@NonRestartableComposable +fun HomeSettingsLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(8.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(0.dp), + content: LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + verticalArrangement = verticalArrangement, + content = content, + ) +} 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 index 582bd69d..5a2d6c78 100644 --- 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 @@ -36,12 +36,13 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.BasicDialog 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.detail.possibleFavoriteTypes 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.ui.search.SearchForDialog import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job @@ -256,7 +257,10 @@ fun HomeSettingsPage( } HomeSettingsDestination.ChooseFavorite -> { + val favoriteTypeOptions = + remember { state.libraries.possibleFavoriteTypes(true) } HomeSettingsFavoriteList( + favoriteTypeOptions = favoriteTypeOptions, onClick = { type -> addRow { viewModel.addFavoriteRow(type) } }, 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 index 438e671f..dc527ed4 100644 --- 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 @@ -9,7 +9,6 @@ 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 @@ -76,9 +75,8 @@ fun HomeSettingsRowList( } Column(modifier = modifier) { TitleText(stringResource(R.string.customize_home)) - LazyColumn( + HomeSettingsLazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .fillMaxHeight() @@ -255,6 +253,24 @@ fun HomeRowConfigContent( fun TitleText( title: String, modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) +} + +@Composable +@NonRestartableComposable +fun SubTitleText( + title: String, + modifier: Modifier = Modifier, ) { Text( text = title, 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 index ee8fa341..e3eafcbb 100644 --- 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 @@ -31,9 +31,11 @@ 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.getRecentlyAddedTitle import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.tvAccess -import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.services.viewOptionsForCollectionType +import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast @@ -43,10 +45,10 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -129,7 +131,7 @@ class HomeSettingsViewModel .let { state -> state.rows .map { row -> - viewModelScope.async(Dispatchers.IO) { + viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { try { homeSettingsService.fetchDataForRow( @@ -273,24 +275,7 @@ class HomeSettingsViewModel rowType: LibraryRowType, ): Job = viewModelScope.launchIO { - val viewOptions = - when (library.collectionType) { - CollectionType.MUSIC -> { - HomeRowViewOptions(aspectRatio = AspectRatio.SQUARE) - } - - CollectionType.HOMEVIDEOS, - CollectionType.MUSICVIDEOS, - -> { - HomeRowViewOptions( - aspectRatio = AspectRatio.WIDE, - ) - } - - else -> { - HomeRowViewOptions() - } - } + val viewOptions = viewOptionsForCollectionType(library.collectionType) val id = idCounter++ val newRow = when (rowType) { @@ -381,7 +366,7 @@ class HomeSettingsViewModel } LibraryRowType.RECENTLY_RECORDED -> { - val title = ResStringProvider(R.string.recently_recorded) + val title = getRecentlyAddedTitle(library.name) HomeRowConfigDisplay( id = id, title = title, @@ -413,10 +398,14 @@ class HomeSettingsViewModel id = id, title = ResProviderStringProvider( - R.string.favorite_items, - ResStringProvider(favoriteOptions[type]!!), + R.string.favorite_items_title, + ResStringProvider(formatTypeName(type)), + ), + config = + HomeRowConfig.Favorite( + kind = type, + viewOptions = HomeRowPresets.WholphinDefault.getByItemType(type), ), - config = HomeRowConfig.Favorite(type), ) updateState { it.copy( @@ -606,7 +595,7 @@ class HomeSettingsViewModel } if (shouldSave) { homeSettingsService.saveToLocal(user.id, settings) - homeSettingsService.updateCurrent(settings) + homeSettingsService.updateCurrent(user.id, settings) showSaveToast() } else { Timber.d("No changes") @@ -627,7 +616,11 @@ class HomeSettingsViewModel _state.update { update.invoke(it) } - homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) } + serverRepository.currentUser?.id?.let { userId -> + homeSettingsService.currentSettings.update { + HomePageResolvedSettings(userId, state.value.rows) + } + } } fun resizeCards(relative: Int) { @@ -708,17 +701,7 @@ class HomeSettingsViewModel } 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 - BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary - else -> preset.movieLibrary - } + val viewOptions = preset.getByItemType(it.config.kind) it.config.updateViewOptions(viewOptions) } 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 761978ef..a65c97e8 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.navigation3.runtime.NavKey +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.DiscoverItem @@ -53,7 +54,11 @@ sealed class Destination( ) : Destination(true) @Serializable - data object Search : Destination() + data class Search( + val query: String = "", + ) : Destination() + + data object UserAppPreferences : Destination(true) @Serializable data class SeriesOverview( @@ -69,6 +74,7 @@ sealed class Destination( val itemId: UUID, val type: BaseItemKind, val collectionType: CollectionType? = null, + val initialSongId: UUID? = null, ) : Destination() { constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } @@ -84,6 +90,7 @@ sealed class Destination( val positionMs: Long, val forceTranscoding: Boolean = false, val backend: PlayerBackend? = null, + val shuffle: Boolean = false, ) : Destination(true) { constructor(item: BaseItem) : this(item.id, item.resumeMs) } @@ -106,6 +113,7 @@ sealed class Destination( val parentType: BaseItemKind, val filter: CollectionFolderFilter, val recursive: Boolean, + val collectionType: CollectionType, ) : Destination(false) @Serializable @@ -143,11 +151,16 @@ sealed class Destination( @Serializable data class DiscoveredItem( val item: DiscoverItem, + // Index to scroll to on the person page grid + val startIndex: Int = 0, ) : Destination(false) + @Serializable data class DiscoverMoreResult( val type: DiscoverRequestType, val startIndex: Int = SEERR_PAGE_SIZE, + val initialFilter: DiscoverFilter = DiscoverFilter(), + val titleOverride: StringProvider? = null, ) : Destination(false) @Serializable 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 15c95717..63789d6c 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 @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.data.rememberSortOptions import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie @@ -35,17 +36,19 @@ import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage +import com.github.damontecres.wholphin.ui.detail.music.SongDetailsPage import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid 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.PlayExternalPage 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.preferences.user.UserProfilePreferencesPage +import com.github.damontecres.wholphin.ui.search.SearchPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind @@ -127,6 +130,10 @@ fun DestinationContent( ) } + Destination.UserAppPreferences -> { + UserProfilePreferencesPage(modifier) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, @@ -251,6 +258,7 @@ fun DestinationContent( AlbumDetailsPage( preferences = preferences, itemId = destination.itemId, + initialSongId = destination.initialSongId, modifier = modifier, ) } @@ -264,6 +272,15 @@ fun DestinationContent( ) } + BaseItemKind.AUDIO -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + SongDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}", modifier) @@ -286,6 +303,7 @@ fun DestinationContent( BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}") }, + sortOptions = rememberSortOptions(destination.collectionType), modifier = modifier, ) } @@ -312,6 +330,7 @@ fun DestinationContent( is Destination.ItemGrid<*> -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } ItemGrid( + preferences, destination, modifier, ) @@ -343,9 +362,10 @@ fun DestinationContent( LicenseInfo(modifier) } - Destination.Search -> { + is Destination.Search -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } SearchPage( + initialQuery = destination.query, userPreferences = preferences, modifier = modifier, ) @@ -356,6 +376,7 @@ fun DestinationContent( } Destination.Discover -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPage( preferences = preferences, modifier = modifier, @@ -384,6 +405,7 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, + startIndex = destination.startIndex, modifier = modifier, ) } @@ -401,6 +423,8 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverRequestGrid( destination = destination, + showTitle = true, + positionCallback = { _, _ -> }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index af4ec63f..efbf1a4b 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 @@ -67,7 +67,6 @@ import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -120,7 +119,6 @@ class NavDrawerViewModel index: Int, item: NavDrawerItem, ) { - if (item !is NavDrawerItem.More) setShowMore(false) when (item) { NavDrawerItem.Favorites -> { setIndex(index) @@ -163,48 +161,40 @@ class NavDrawerViewModel fun updateSelectedIndex() { viewModelScope.launchDefault { val asDestinations = - ( - serviceState.value.items + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + serviceState.value.moreItems - ).map { - if (it is ServerNavDrawerItem) { - it.destination - } else if (it is NavDrawerItem.Favorites) { - Destination.Favorites - } else if (it is NavDrawerItem.Discover) { - Destination.Discover - } else { - null + buildList { + addAll(serviceState.value.items) + addAll(serviceState.value.moreItems) + }.map { + when (it) { + is ServerNavDrawerItem -> it.destination + is NavDrawerItem.Favorites -> Destination.Favorites + is NavDrawerItem.Discover -> Destination.Discover + else -> null } } val backstack = navigationManager.backStack.toList().reversed() for (i in 0..<backstack.size) { val key = backstack[i] - if (key is Destination) { - val index = - if (key is Destination.Home) { - HOME_INDEX - } else if (key is Destination.Search) { - SEARCH_INDEX - } else if (key is Destination.NowPlaying) { - NOW_PLAYING_INDEX + val index = + if (key is Destination.Home) { + HOME_INDEX + } else if (key is Destination.Search) { + SEARCH_INDEX + } else if (key is Destination.NowPlaying) { + NOW_PLAYING_INDEX + } else { + val idx = asDestinations.indexOf(key) + if (idx >= 0) { + idx } else { - val idx = asDestinations.indexOf(key) - if (idx >= 0) { - idx - } else { - null - } + null } - Timber.v("Found $index => $key") - if (index != null) { - _state.update { it.copy(selectedIndex = index) } - break } + Timber.v("Found $index => $key") + if (index != null) { + _state.update { it.copy(selectedIndex = index) } + break } } } @@ -419,7 +409,7 @@ fun NavDrawer( interactionSource = interactionSource, onClick = { viewModel.setIndex(SEARCH_INDEX) - viewModel.navigationManager.navigateToFromDrawer(Destination.Search) + viewModel.navigationManager.navigateToFromDrawer(Destination.Search()) }, modifier = Modifier @@ -442,6 +432,7 @@ fun NavDrawer( viewModel.setIndex(HOME_INDEX) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() + onClearBackdrop.invoke() } else { viewModel.navigationManager.goToHome() } @@ -455,25 +446,23 @@ fun NavDrawer( ) } itemsIndexed(serviceState.items) { index, it -> - if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) { - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) } if (serviceState.moreItems.isNotEmpty()) { item { @@ -481,7 +470,7 @@ fun NavDrawer( val interactionSource = remember { MutableInteractionSource() } NavItem( library = NavDrawerItem.More, - selected = selectedIndex == index, + selected = false, moreExpanded = moreExpanded, drawerOpen = isOpen, interactionSource = interactionSource, @@ -500,35 +489,33 @@ fun NavDrawer( if (moreExpanded) { itemsIndexed(serviceState.moreItems) { index, it -> val adjustedIndex = - remember(serviceState) { (index + serviceState.items.size + 1) } - if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) { - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - onClick = { - viewModel.onClickDrawerItem( - adjustedIndex, - it, - ) + remember(serviceState) { (index + serviceState.items.size) } + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == adjustedIndex, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surface.copy(alpha = .5f) + } else { + Color.Unspecified }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - selectedIndex == adjustedIndex, - Modifier.focusRequester(focusRequester), - ), - ) - } + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) } } item { @@ -785,6 +772,8 @@ fun navItemColor( AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border AppThemeColors.BOLD_BLUE, 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 40b5e89b..380901a1 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 @@ -4,7 +4,10 @@ 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 com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.extensions.ticks @@ -26,6 +29,40 @@ data class CurrentMediaInfo( } } +/** + * Gets a sorted list of subtitle streams in a [MediaSourceInfo] + */ +internal fun PlaybackViewModel.getSubtitleStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> { + val subtitleLanguagePreference = + serverRepository.currentUserDto + ?.configuration + ?.subtitleLanguagePreference + return mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + .let { + if (subtitleLanguagePreference.isNotNullOrBlank()) { + it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } + } else { + it + } + }?.map { + // TODO should use a string provider instead + SimpleMediaStream.from(context.resources, it, true) + }.orEmpty() +} + +/** + * Gets a list of audio streams in a [MediaSourceInfo] + */ +internal fun PlaybackViewModel.getAudioStreams(mediaSource: MediaSourceInfo): List<SimpleMediaStream> = + mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + SimpleMediaStream.from(context.resources, it, true) + } +// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) + .orEmpty() + fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata = MediaMetadata .Builder() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt index b0f51e56..54663af6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt @@ -1,11 +1,14 @@ package com.github.damontecres.wholphin.ui.playback import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.TrackSupport import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.TranscodingInfo +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID import kotlin.time.Duration /** @@ -25,4 +28,12 @@ data class CurrentPlayback( val audioDecoder: String? = null, val transcodeInfo: TranscodingInfo? = null, val subtitleDelay: Duration = Duration.ZERO, -) + val audioIndex: Int = TrackIndex.UNSPECIFIED, + val subtitleIndex: Int = TrackIndex.UNSPECIFIED, +) { + val audioIndexEnabled = audioIndex >= 0 + val subtitleIndexEnabled = subtitleIndex >= 0 + + val itemId: UUID get() = item.id + val sourceId: UUID? = mediaSourceInfo.id?.toUUIDOrNull() +} 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 c9845191..2ec02410 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 @@ -1,21 +1,15 @@ package com.github.damontecres.wholphin.ui.playback 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 -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,11 +19,9 @@ 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.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -37,15 +29,13 @@ import androidx.tv.material3.Icon import androidx.tv.material3.ListItem 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.AppColors +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.abbreviateNumber -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogItemDivider import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.RemoteSubtitleInfo @@ -55,53 +45,41 @@ fun DownloadSubtitlesContent( language: String, onSearch: (String) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit, - onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { when (val s = state) { SubtitleSearchStatus.Inactive -> {} SubtitleSearchStatus.Searching -> { - Wrapper { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } SubtitleSearchStatus.Downloading -> { - Wrapper { - Text( - text = stringResource(R.string.downloading), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.downloading), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } is SubtitleSearchStatus.Error -> { - Wrapper { ErrorMessage(null, s.ex, modifier) } + ErrorMessage(null, s.ex, modifier) } is SubtitleSearchStatus.Success -> { - val dialogItems = convertRemoteSubtitles(s.options, onClickDownload) val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + modifier = modifier, ) { Text( text = stringResource(R.string.search_and_download_subtitles), @@ -128,51 +106,35 @@ fun DownloadSubtitlesContent( onSearch(lang) }, ), - // onKeyboardAction = { -// onSearch(lang.text.toString()) -// }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Search, ), ) } - if (dialogItems.isEmpty()) { + if (s.options.isEmpty()) { Text( text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) } else { + val focusRequesters = + remember(s.options.size) { List(s.options.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } LazyColumn( modifier = Modifier, ) { - itemsIndexed(dialogItems) { index, item -> - when (item) { - is DialogItemDivider -> { - HorizontalDivider(Modifier.height(16.dp)) - } - - is DialogItem -> { - ListItem( - selected = false, - enabled = item.enabled, - onClick = { - item.onClick.invoke() - }, - headlineContent = item.headlineContent, - overlineContent = item.overlineContent, - supportingContent = item.supportingContent, - leadingContent = item.leadingContent, - trailingContent = item.trailingContent, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), - ) - } - } + itemsIndexed(s.options) { index, item -> + SubtitleInfo( + subtitle = item, + onClick = onClickDownload, + modifier = + Modifier + .focusRequester(focusRequesters[index]), + ) } } } @@ -182,54 +144,56 @@ fun DownloadSubtitlesContent( } @Composable -private fun Wrapper(content: @Composable BoxScope.() -> Unit) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - Box( - modifier = - Modifier - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), - content = content, - ) -} - -fun convertRemoteSubtitles( - options: List<RemoteSubtitleInfo>, +fun SubtitleInfo( + subtitle: RemoteSubtitleInfo, onClick: (RemoteSubtitleInfo) -> Unit, -) = options.map { op -> - DialogItem( - onClick = { onClick.invoke(op) }, + modifier: Modifier = Modifier, +) { + ListItem( + selected = false, + onClick = { onClick.invoke(subtitle) }, headlineContent = { Text( - text = op.name ?: "", + text = subtitle.name ?: "", ) }, + overlineContent = + if (subtitle.isHashMatch == true) { + { + Text(stringResource(R.string.hash_matches)) + } + } else { + null + }, supportingContent = { - val strings = - buildList { - op.providerName?.let(::add) - op.threeLetterIsoLanguageName?.let(::add) - if (op.forced == true) { - add(stringResource(R.string.forced_track)) - } - add( - pluralStringResource( - R.plurals.downloads, - op.downloadCount ?: 0, - abbreviateNumber(op.downloadCount ?: 0), - ), - ) + val resources = LocalResources.current + val details = + remember(resources, subtitle) { + val strings = + buildList { + subtitle.providerName?.let(::add) + if (subtitle.forced == true) { + add(resources.getString(R.string.forced_track)) + } + if (subtitle.hearingImpaired == true) { + add(resources.getString(R.string.subtitles_hearing_impaired)) + } + add( + resources.getQuantityString( + R.plurals.downloads, + subtitle.downloadCount ?: 0, + abbreviateNumber(subtitle.downloadCount ?: 0), + ), + ) + } + strings.joinToString(" - ") } Text( - text = strings.joinToString(" - "), + text = details, ) }, trailingContent = { - op.communityRating?.let { rating -> + subtitle.communityRating?.let { rating -> Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -245,5 +209,43 @@ fun convertRemoteSubtitles( } } }, + modifier = modifier, ) } + +@PreviewTvSpec +@Composable +fun SubtitleInfoPreview() { + WholphinTheme { + Column(Modifier.width(400.dp)) { + SubtitleInfo( + onClick = {}, + subtitle = + RemoteSubtitleInfo( + name = "Filename.mkv", + providerName = "OpenSubs", + threeLetterIsoLanguageName = "eng", + forced = true, + hearingImpaired = false, + isHashMatch = true, + communityRating = 7.5f, + downloadCount = 10_500, + ), + ) + SubtitleInfo( + onClick = {}, + subtitle = + RemoteSubtitleInfo( + name = "Filename.mkv", + providerName = "OpenSubs", + threeLetterIsoLanguageName = "eng", + forced = false, + hearingImpaired = true, + isHashMatch = false, + communityRating = 7.5f, + downloadCount = 10_500, + ), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt index f032c8b7..d93ffda6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt @@ -10,10 +10,8 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -21,6 +19,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,7 +28,10 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -47,10 +49,17 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.Clock +import com.github.damontecres.wholphin.ui.util.LocalClock import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration /** * Layout for showing the next up episode during playback @@ -64,6 +73,7 @@ fun NextUpEpisode( timeLeft: Duration?, modifier: Modifier = Modifier, aspectRatio: Float = AspectRatios.WIDE, + runtime: Duration? = null, ) { val interactionSource = remember { MutableInteractionSource() } val focusRequester = remember { FocusRequester() } @@ -122,9 +132,36 @@ fun NextUpEpisode( text = description ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + runtime?.let { + val context = LocalContext.current + val resources = LocalResources.current + val now by LocalClock.current.now + val text = + remember(now, runtime) { + buildAnnotatedString { + append(resources.formatDuration(runtime)) + dot() + val endsAt = now.plus(runtime.toJavaDuration()) + val endTimeStr = formatTime(context, endsAt) + append(resources.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(top = 4.dp) + .align(Alignment.Start), + ) + } } } } @@ -189,34 +226,41 @@ fun NextUpCard( @Composable private fun NextUpEpisodePreview() { WholphinTheme(true) { + val theme = MaterialTheme.colorScheme val previewHandler = AsyncImagePreviewHandler { - ColorImage(Color.Blue.toArgb()) + ColorImage(theme.surfaceVariant.toArgb()) + } + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + ) { + CompositionLocalProvider( + LocalAsyncImagePreviewHandler provides previewHandler, + LocalClock provides Clock(), + ) { + NextUpEpisode( + title = "Episode Title", + description = + "This is the description of the episode. It might be long. " + + "It might be very, very long and overflow the available space. " + + "But it just keeps going and going.", + imageUrl = "", + onClick = {}, + aspectRatio = AspectRatios.WIDE, + timeLeft = 30.seconds, + runtime = 32.minutes + 20.seconds, + modifier = + Modifier + .padding(8.dp) + .fillMaxHeight(.4f) + .fillMaxWidth(.66f) + .align(Alignment.BottomCenter) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + shape = RoundedCornerShape(8.dp), + ), + ) } - - CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) { - NextUpEpisode( - title = "Episode Title", - description = - "This is the description of the episode. It might be long. " + - "It might be very, very long and overflow the available space. " + - "But it just keeps going and going.", - imageUrl = "", - onClick = {}, - aspectRatio = AspectRatios.FOUR_THREE, - timeLeft = 30.seconds, - modifier = - Modifier - .padding(16.dp) - .height(200.dp) - .width(400.dp) -// .fillMaxWidth(.4f) -// .align(Alignment.BottomCenter) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - shape = RoundedCornerShape(8.dp), - ), - ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index a9e4b61d..871264c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -338,12 +338,15 @@ class PlayExternalViewModel } } Timber.v("Result position: %s", position?.milliseconds) + if (position != null && position < 0L) { + Timber.w("Unknown stop position for external playback") + } if (position != null || result.data?.action != null) { api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = itemId, mediaSourceId = mediaSourceId, - positionTicks = position?.milliseconds?.inWholeTicks, + positionTicks = position?.milliseconds?.inWholeTicks?.takeIf { it >= 0 }, failed = false, ), ) 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 c3bff78c..491498cb 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity 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.fillMaxWidth @@ -11,15 +12,21 @@ import androidx.compose.foundation.layout.wrapContentSize 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue 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.focusRequester +import androidx.compose.ui.input.key.KeyEventType +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.LocalLayoutDirection import androidx.compose.ui.platform.LocalView @@ -42,6 +49,7 @@ import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch import kotlin.time.Duration enum class PlaybackDialogType { @@ -276,6 +284,24 @@ fun SubtitleChoiceBottomDialog( hasDownloadPermission: Boolean, currentChoice: Int? = null, ) { + val numberBefore = 2 + val initialChoice = + remember(choices) { + choices.indexOfFirstOrNull { it.index == currentChoice } + } + val scope = rememberCoroutineScope() + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialChoice?.plus(numberBefore) ?: 0, + ) + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } + val choiceFocusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + LaunchedEffect(Unit) { + val focusRequester = + initialChoice?.let { choiceFocusRequesters.getOrNull(it) } ?: firstFocusRequester + focusRequester.tryRequestFocus() + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -298,6 +324,7 @@ fun SubtitleChoiceBottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -307,8 +334,11 @@ fun SubtitleChoiceBottomDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { item { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = currentChoice == TrackIndex.DISABLED, + interactionSource = interactionSource, onClick = { onSelectChoice(TrackIndex.DISABLED) }, @@ -321,6 +351,19 @@ fun SubtitleChoiceBottomDialog( ) }, supportingContent = {}, + modifier = + Modifier + .focusRequester(firstFocusRequester) + .onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(Int.MAX_VALUE) + lastFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, ) } item { @@ -339,6 +382,9 @@ fun SubtitleChoiceBottomDialog( }, supportingContent = {}, ) + if (choices.isNotEmpty()) { + HorizontalDivider() + } } itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } @@ -359,10 +405,13 @@ fun SubtitleChoiceBottomDialog( if (choice.streamTitle != null) Text(choice.displayTitle) }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(choiceFocusRequesters[index]), ) } item { HorizontalDivider() + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = false, enabled = hasDownloadPermission, @@ -374,6 +423,20 @@ fun SubtitleChoiceBottomDialog( ) }, supportingContent = {}, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(lastFocusRequester) + .onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + return@onKeyEvent true + } + false + }, ) } } 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 7209fbd9..29bd30b2 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 @@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.media3.common.Player import androidx.media3.common.util.Util +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import kotlin.time.Duration @@ -15,7 +16,6 @@ import kotlin.time.Duration * Handles [KeyEvent]s during playback on [PlaybackPage] */ class PlaybackKeyHandler( - private val isLtr: Boolean, private val player: Player, private val controlsEnabled: Boolean, private val skipWithLeftRight: Boolean, @@ -24,11 +24,15 @@ class PlaybackKeyHandler( private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, + private val clearSkipIndicator: () -> Unit, private val skipBackOnResume: Duration?, private val oneClickPause: Boolean, private val onInteraction: () -> Unit, private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, + private val isDpadSeekVisible: () -> Boolean = { false }, + private val onDpadSeek: (Long) -> Unit = { }, + private val dpadSeekMode: DpadSeekMode, ) { private var leftHandledByRepeat = false private var rightHandledByRepeat = false @@ -48,21 +52,12 @@ class PlaybackKeyHandler( if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { - if (isLtr) { - updateSkipIndicator(-seekBack.inWholeMilliseconds) - player.seekBack(seekBack) - } else { - updateSkipIndicator(seekForward.inWholeMilliseconds) - player.seekForward(seekForward) - } + seekBy(-seekBack) } else if (skipWithLeftRight && isSkipForward(it)) { - if (isLtr) { - player.seekForward(seekForward) - updateSkipIndicator(seekForward.inWholeMilliseconds) - } else { - player.seekBack(seekBack) - updateSkipIndicator(seekBack.inWholeMilliseconds) - } + seekBy(seekForward) + } else if (isEnterKey(it) && isDpadSeekVisible()) { + // If d-pad seek bar is visible, hide it + clearSkipIndicator.invoke() } else if (oneClickPause && isEnterKey(it)) { val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) @@ -138,7 +133,7 @@ class PlaybackKeyHandler( return false } - val isBack = if (isLtr) isSkipBack(event) else isSkipForward(event) + val isBack = isSkipBack(event) return when (event.type) { KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount @@ -180,12 +175,24 @@ class PlaybackKeyHandler( ) { if (isBack) { val skipDuration = seekBack * multiplier - player.seekBack(skipDuration) - updateSkipIndicator(-skipDuration.inWholeMilliseconds) + seekBy(-skipDuration) } else { val skipDuration = seekForward * multiplier - player.seekForward(skipDuration) - updateSkipIndicator(skipDuration.inWholeMilliseconds) + seekBy(skipDuration) + } + } + + private fun seekBy(duration: Duration) { + val durationMs = duration.inWholeMilliseconds + if (dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY) { + onDpadSeek.invoke(durationMs) + } else { + if (duration < Duration.ZERO) { + player.seekBack(-duration) + } else { + player.seekForward(duration) + } + updateSkipIndicator(durationMs) } } 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 d25375a2..6ed210a2 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 @@ -4,7 +4,6 @@ import android.view.Gravity import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.compose.BackHandler -import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing @@ -12,6 +11,8 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape 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 @@ -54,7 +56,6 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -68,33 +69,37 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AssPlaybackMode +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.BasicDialog 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.overlay.DpadSeekOverlay import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay import com.github.damontecres.wholphin.ui.playback.overlay.SkipIndicator import com.github.damontecres.wholphin.ui.playback.overlay.SkipSegmentButton import com.github.damontecres.wholphin.ui.playback.overlay.rememberSeekBarState +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyTo 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.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride -import com.github.damontecres.wholphin.util.mpv.MpvPlayer import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -161,6 +166,7 @@ fun PlaybackPageContent( val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val density = LocalDensity.current + LaunchedEffect(density) { viewModel.updateDensity(density) } val userDto by viewModel.currentUserDto.collectAsState() var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -221,11 +227,26 @@ fun PlaybackPageContent( skipIndicatorDuration += delta skipPosition = player.currentPosition } - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val onDpadSeek: (Long) -> Unit = { deltaMs -> + if (skipIndicatorDuration == 0L) { + skipPosition = player.currentPosition + } + if ((skipIndicatorDuration > 0 && deltaMs < 0) || + (skipIndicatorDuration < 0 && deltaMs > 0) + ) { + skipIndicatorDuration = 0L + } + skipIndicatorDuration += deltaMs + skipPosition = + (skipPosition + deltaMs).coerceIn( + minimumValue = 0L, + maximumValue = player.duration.coerceAtLeast(0L), + ) + seekBarState.onValueChange(skipPosition) + } val keyHandler = - remember(isLtr, preferences) { + remember(preferences) { PlaybackKeyHandler( - isLtr = isLtr, player = player, controlsEnabled = state.nextUp == null, skipWithLeftRight = true, @@ -234,6 +255,7 @@ fun PlaybackPageContent( getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, + clearSkipIndicator = { skipIndicatorDuration = 0 }, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, @@ -242,6 +264,11 @@ fun PlaybackPageContent( viewModel.navigationManager.goBack() }, onPlaybackDialogTypeClick = { playbackDialog = it }, + isDpadSeekVisible = { + prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY && skipIndicatorDuration != 0L + }, + onDpadSeek = onDpadSeek, + dpadSeekMode = prefs.dpadSeekMode, ) } @@ -287,7 +314,7 @@ fun PlaybackPageContent( PlaybackAction.Previous -> { val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && state.playlist.hasPrevious()) { + if (pos < player.maxSeekToPreviousPosition && state.hasPrevious) { viewModel.playPrevious() } else { player.seekToPrevious() @@ -351,8 +378,42 @@ fun PlaybackPageContent( } } + AnimatedVisibility( + visible = + !controllerViewState.controlsVisible && + skipIndicatorDuration != 0L && + prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + DpadSeekOverlay( + player = player, + seekPositionMs = skipPosition, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .fillMaxWidth(.95f), + ) + } + // Clear the overlay after a delay + LaunchedEffect(skipIndicatorDuration) { + delay(1.5.seconds) + skipIndicatorDuration = 0L + } + } + // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && + prefs.dpadSeekMode != DpadSeekMode.SEEKBAR_TRICKPLAY + ) { + // Skip time mode: show seek distance indicator SkipIndicator( durationMs = skipIndicatorDuration, onFinish = { @@ -364,22 +425,35 @@ fun PlaybackPageContent( .padding(bottom = 70.dp), ) // Show a small progress bar along the bottom of the screen - val showSkipProgress = true // TODO get from preferences - if (showSkipProgress) { + if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) { val percent = skipPosition.toFloat() / player.duration.toFloat() - Box( - modifier = + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box( Modifier - .align(Alignment.BottomStart) - .background(MaterialTheme.colorScheme.border) - .clip(RectangleShape) - .height(3.dp) - .fillMaxWidth(percent), - ) + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background(MaterialTheme.colorScheme.border) + .clip(RectangleShape) + .height(3.dp) + .fillMaxWidth(percent), + ) + } + } } } - if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L) { + val controlsVisible = + remember(controllerViewState.controlsVisible, playbackDialog, subtitleSearchState) { + controllerViewState.controlsVisible || + playbackDialog != null || + subtitleSearchState.status != SubtitleSearchStatus.Inactive + } + if (!controlsVisible && skipIndicatorDuration == 0L) { PauseIndicator( player = player, modifier = @@ -401,7 +475,7 @@ fun PlaybackPageContent( controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, previousEnabled = true, - nextEnabled = state.playlist.hasNext(), + nextEnabled = state.hasNext, seekEnabled = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, @@ -414,19 +488,20 @@ fun PlaybackPageContent( chapters = state.currentMediaInfo.chapters, trickplayInfo = state.currentMediaInfo.trickPlayInfo, trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = state.playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, + queue = remember(state.playlistIndex, state.playlist) { state.upcomingItems }, + onClickPlaylist = viewModel::playItemInPlaylist, currentSegment = state.currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, analyticsState = state.analyticsState, ) + var subtitleStyleAppliedForHdr by remember { mutableStateOf(state.currentMediaInfo.videoStream?.hdr == true) } val subtitleSettings = remember(state.currentMediaInfo) { Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}") - if (state.currentMediaInfo.videoStream?.hdr == true) { + val useSeparate = + preferences.appPreferences.interfacePreferences.subtitlesPreferences.useSeparateHdr + if (useSeparate && state.currentMediaInfo.videoStream?.hdr == true) { preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.appPreferences.interfacePreferences.subtitlesPreferences @@ -439,21 +514,19 @@ fun PlaybackPageContent( val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null } - var cueCount by remember { mutableIntStateOf(0) } + + // Reset cueCount when density changes to force re-applying subtitle style + var cueCount by remember(density) { mutableIntStateOf(0) } val subtitleVisible = skipIndicatorDuration == 0L && - state.currentItemPlayback?.subtitleIndexEnabled == true && + state.currentPlayback?.subtitleIndexEnabled == true && !presentationState.coverSurface AndroidView( factory = { context -> SubtitleView(context).apply { - subtitleSettings.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } + subtitleSettings.applyTo(this) playerInstance.assHandler?.let { assHandler -> if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { Timber.v("Adding AssSubtitleView") @@ -472,8 +545,14 @@ fun PlaybackPageContent( } }, update = { subtitleView -> + if (subtitleStyleAppliedForHdr != state.currentMediaInfo.videoStream?.hdr) { + // If the media has switched between SDR & HDR, reapply subtitle settings + Timber.v("Switching SDR/HDR, reapplying subtitle style") + subtitleSettings.applyTo(subtitleView) + } subtitleView.setCues(state.subtitleCues) if (state.subtitleCues.size > cueCount) { + Timber.i("Applying subtitle style to SubtitleView") // The output creates a painter for each cue, so need to apply the changes when the number of cues increases Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(subtitleView) @@ -599,6 +678,7 @@ fun PlaybackPageContent( viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + runtime = it.data.runTimeTicks?.ticks, modifier = Modifier .padding(8.dp) @@ -626,28 +706,29 @@ fun PlaybackPageContent( } viewModel.cancelSubtitleSearch() } - Dialog( + BasicDialog( onDismissRequest = onDismissRequest, properties = DialogProperties( usePlatformDefaultWidth = false, ), ) { - DownloadSubtitlesContent( - state = subtitleSearchState.status, - language = subtitleSearchState.language, - onSearch = { lang -> - viewModel.searchForSubtitles(lang) - }, - onClickDownload = { - viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) - }, - onDismissRequest = onDismissRequest, - modifier = - Modifier - .widthIn(max = 640.dp) - .heightIn(max = 400.dp), - ) + Box(modifier = Modifier.padding(24.dp)) { + DownloadSubtitlesContent( + state = subtitleSearchState.status, + language = subtitleSearchState.language, + onSearch = { lang -> + viewModel.searchForSubtitles(lang) + }, + onClickDownload = { + viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) + }, + modifier = + Modifier + .widthIn(max = 640.dp) + .heightIn(max = 400.dp), + ) + } } } @@ -657,9 +738,9 @@ fun PlaybackPageContent( settings = PlaybackSettings( showDebugInfo = showDebugInfo, - audioIndex = state.currentItemPlayback?.audioIndex, + audioIndex = state.currentPlayback?.audioIndex, audioStreams = state.currentMediaInfo.audioStreams, - subtitleIndex = state.currentItemPlayback?.subtitleIndex, + subtitleIndex = state.currentPlayback?.subtitleIndex, subtitleStreams = state.currentMediaInfo.subtitleStreams, playbackSpeed = playbackSpeed, contentScale = contentScale, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt index a520df2e..b42b7151 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -6,6 +6,7 @@ import androidx.media3.common.text.Cue import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.util.LoadingState @@ -21,8 +22,20 @@ data class PlaybackState( val analyticsState: AnalyticsState = AnalyticsState(), val subtitleCues: List<Cue> = emptyList(), val nextUp: BaseItem? = null, - val playlist: Playlist = Playlist(listOf()), -) + val playlistIndex: Int = 0, + val playlist: Playlist = Playlist(emptyList()), +) { + val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size + val hasPrevious: Boolean get() = playlistIndex > 0 + val upcomingItems: List<PlaylistItem> + get() = + playlist.items.subList( + (playlistIndex + 1).coerceAtMost(playlist.items.size), + playlist.items.size, + ) + + fun nextItem(): PlaylistItem? = playlist.items.getOrNull(playlistIndex + 1) +} data class PlayerInstance( val player: Player, 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 631aacc4..79d2ae57 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 @@ -31,15 +31,16 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService @@ -53,6 +54,7 @@ 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.formatBitrate +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -62,13 +64,12 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings. import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.checkForSupport -import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.subtitleMimeTypes @@ -79,11 +80,11 @@ 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.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -92,6 +93,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi @@ -138,8 +140,8 @@ class PlaybackViewModel val navigationManager: NavigationManager, private val playlistCreator: PlaylistCreator, private val itemPlaybackDao: ItemPlaybackDao, - private val serverRepository: ServerRepository, - private val itemPlaybackRepository: ItemPlaybackRepository, + internal val serverRepository: ServerRepository, + internal val itemPlaybackRepository: ItemPlaybackRepository, private val playerFactory: PlayerFactory, private val datePlayedService: DatePlayedService, private val deviceInfo: DeviceInfo, @@ -164,7 +166,10 @@ class PlaybackViewModel internal lateinit var player: Player private var mediaSession: MediaSession? = null - internal val mutex = Mutex() + + // Mutex & Job for changing playlist index + private val playlistMutex = Mutex() + private var playlistJob: Job? = null val controllerViewState = ControllerViewState( @@ -175,11 +180,12 @@ class PlaybackViewModel private val _state = MutableStateFlow(PlaybackState()) val state: StateFlow<PlaybackState> = _state - private lateinit var preferences: UserPreferences + internal lateinit var preferences: UserPreferences internal lateinit var itemId: UUID internal lateinit var currentItem: PlaylistItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null + private var trackChangeListener: TracksChangedListener? = null private val jobs = mutableListOf<Job>() private val isPlaylist = destination is Destination.PlaybackList @@ -188,14 +194,18 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDtoFlow + // Exposed for testing + val initJob: Job + init { - viewModelScope.launchIO { - addCloseable { - screensaverService.keepScreenOn(false) - disconnectPlayer() + initJob = + viewModelScope.launchIO { + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } + init() } - init() - } } private fun disconnectPlayer() { @@ -207,6 +217,9 @@ class PlaybackViewModel it.release() player.removeListener(it) } + this@PlaybackViewModel.trackChangeListener?.let { + player.removeListener(it) + } player.release() mediaSession?.release() } @@ -238,14 +251,14 @@ class PlaybackViewModel Timber.d("Selected backend: %s", playerBackend) if (currentPlayer.value?.backend != playerBackend) { Timber.i("Switching player backend to %s", playerBackend) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { disconnectPlayer() } val playerCreation = playerFactory.createVideoPlayer( playerBackend, - preferences.appPreferences.playbackPreferences, + preferences.appPreferences, ) this.player = playerCreation.player currentPlayer.update { @@ -265,10 +278,7 @@ class PlaybackViewModel player, preferences.appPreferences.playbackPreferences, ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() + mediaSession = playerFactory.createMediaSession(sessionPlayer) } /** @@ -311,16 +321,31 @@ class PlaybackViewModel val playlistItem = if (queriedItem.type.playable) { PlaylistItem.Media(BaseItem(queriedItem, false)) - } else if (destination is Destination.PlaybackList) { + } else { val playlistResult = - playlistCreator.createFrom( - item = queriedItem, - startIndex = destination.startIndex ?: 0, - sortAndDirection = destination.sortAndDirection, - shuffled = destination.shuffle, - recursive = destination.recursive, - filter = destination.filter, - ) + if (destination is Destination.PlaybackList) { + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + } else { + val shuffled = + if (destination is Destination.Playback) { + destination.shuffle + } else { + false + } + // Try to create a playlist + playlistCreator.createFrom( + item = queriedItem, + recursive = true, + shuffled = shuffled, + ) + } when (val r = playlistResult) { is PlaylistCreationResult.Error -> { _state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) } @@ -339,13 +364,10 @@ class PlaybackViewModel r.playlist.items.first() } } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - playlistItem.item.type == BaseItemKind.TRAILER val intros = // If not resuming playback & cinema mode is enabled, get potential intros if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) { @@ -393,7 +415,7 @@ class PlaybackViewModel } } - private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { + internal fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { _state.update { it.copy(currentPlayback = block.invoke(it.currentPlayback)) } @@ -412,7 +434,7 @@ class PlaybackViewModel positionMs: Long, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val item = when (playlistItem) { is PlaylistItem.Intro -> playlistItem.item @@ -456,10 +478,16 @@ class PlaybackViewModel } } } - val mediaSource = streamChoiceService.chooseSource(base, itemPlayback) + val mediaSource = + if (!isLiveTv) { + streamChoiceService.chooseSource(base, itemPlayback) + } else { + null + } + val plc = streamChoiceService.getPlaybackLanguageChoice(base) - if (mediaSource == null) { + if (mediaSource == null && !isLiveTv) { showToast( context, "Item has no media sources, skipping...", @@ -469,7 +497,8 @@ class PlaybackViewModel } val videoStream = - mediaSource.mediaStreams + mediaSource + ?.mediaStreams ?.firstOrNull { it.type == MediaStreamType.VIDEO } ?.let { val isHdr = @@ -482,70 +511,49 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - val subtitleLanguagePreference = - serverRepository.currentUserDto - ?.configuration - ?.subtitleLanguagePreference - val subtitleStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - .let { - if (subtitleLanguagePreference.isNotNullOrBlank()) { - it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } - } else { - it - } - }?.map { - // TODO should use a string provider instead - SimpleMediaStream.from(context.resources, it, true) - }.orEmpty() - val audioStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - SimpleMediaStream.from(context.resources, it, true) - } -// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) - .orEmpty() + val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() + val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() val audioStream = - streamChoiceService - .chooseAudioStream( - source = mediaSource, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - ) + mediaSource?.let { + streamChoiceService + .chooseAudioStream( + source = mediaSource, + seriesId = base.seriesId, + itemPlayback = itemPlayback, + plc = plc, + prefs = preferences, + ) + } val audioIndex = audioStream?.index val subtitleIndex = - streamChoiceService - .chooseSubtitleStream( - source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, - itemPlayback = itemPlayback, - plc = plc, - prefs = preferences, - )?.index + mediaSource?.let { + streamChoiceService + .chooseSubtitleStream( + source = mediaSource, + audioStream = audioStream, + seriesId = base.seriesId, + itemPlayback = itemPlayback, + plc = plc, + prefs = preferences, + )?.index + } + Timber.d( + "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", + mediaSource?.id, + audioIndex, + subtitleIndex, + ) - Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - - val itemPlaybackToUse = - itemPlayback ?: ItemPlayback( - rowId = -1, - userId = -1, - itemId = base.id, - sourceId = if (!isLiveTv) mediaSource.id?.toUUIDOrNull() else null, - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, - ) val trickPlayInfo = - item.data.trickplay - ?.get(mediaSource.id) - ?.values - ?.firstOrNull() + mediaSource?.id?.let { sourceId -> + item.data.trickplay + ?.get(sourceId) + ?.values + ?.firstOrNull() + } + trickPlayInfo?.let { trickplayInfo -> mediaSource.runTimeTicks?.ticks?.let { duration -> viewModelScope.launchIO { @@ -558,13 +566,16 @@ class PlaybackViewModel } } - val chapters = Chapter.fromDto(base, api) + val chapters = Chapter.fromDto(base) _state.update { - it.copy(currentItemPlayback = itemPlaybackToUse) + it.copy( + currentItemPlayback = itemPlayback, + currentPlayback = null, + ) } updateCurrentMedia { CurrentMediaInfo( - sourceId = mediaSource.id, + sourceId = mediaSource?.id, videoStream = videoStream, audioStreams = audioStreams, subtitleStreams = subtitleStreams, @@ -572,14 +583,13 @@ class PlaybackViewModel trickPlayInfo = trickPlayInfo, ) } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { changeStreams( - item, - itemPlaybackToUse, - audioIndex, - subtitleIndex, - if (positionMs > 0) positionMs else C.TIME_UNSET, - itemPlayback != null, // If it was passed in, then it was not queried from the database + item = item, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, + sourceId = mediaSource?.id, enableDirectPlay = !forceTranscoding, enableDirectStream = !forceTranscoding, ) @@ -596,238 +606,272 @@ class PlaybackViewModel @OptIn(UnstableApi::class) internal suspend fun changeStreams( item: BaseItem, - currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!, + sourceId: String?, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = 0, - userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, - ) = withContext(Dispatchers.IO) { - val itemId = item.id + ): Unit = + withContext(WholphinDispatchers.IO) { + val itemId = item.id - val currentPlayback = state.value.currentPlayback - if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { - val wasSuccessful = - changeStreamsDirectPlay( - currentPlayback = currentPlayback, - currentItemPlayback = currentItemPlayback, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - userInitiated = userInitiated, - ) - if (wasSuccessful) return@withContext - } + trackChangeListener?.let { onMain { player.removeListener(it) } } + trackChangeListener = null - Timber.d( - "changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " + - "enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs", - ) - - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE - val response by - api.mediaInfoApi - .getPostedPlaybackInfo( - itemId, - PlaybackInfoDto( - startTimeTicks = null, - deviceProfile = - if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { - deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences.playbackPreferences, - serverRepository.currentServer?.serverVersion, - ) - } else { - mpvDeviceProfile - }, - maxAudioChannels = null, - audioStreamIndex = audioIndex, - subtitleStreamIndex = subtitleIndex, - mediaSourceId = currentItemPlayback.sourceId?.toServerString(), - alwaysBurnInSubtitleWhenTranscoding = false, - maxStreamingBitrate = maxBitrate.toInt(), - enableDirectPlay = enableDirectPlay, - enableDirectStream = enableDirectStream, - allowVideoStreamCopy = enableDirectStream, - allowAudioStreamCopy = enableDirectStream, - enableTranscoding = true, - autoOpenLiveStream = true, - ), - ) - if (response.errorCode != null) { - _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } - return@withContext - } - val source = response.mediaSources.firstOrNull() - source?.let { source -> - val mediaUrl = - if (source.supportsDirectPlay) { - if (source.isRemote && source.path.isNotNullOrBlank()) { - Timber.i("Playback is remote for source: %s", source.id) - source.path - } else { - api.videosApi.getVideoStreamUrl( - itemId = itemId, - mediaSourceId = source.id, - static = true, - tag = source.eTag, - playSessionId = response.playSessionId, + state.value.currentPlayback?.let { currentPlayback -> + if (currentPlayback.item.id == item.id && + currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && + enableDirectPlay + ) { + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, ) - } - } else if (source.supportsDirectStream) { - source.transcodingUrl?.let(api::createUrl) - } else { - source.transcodingUrl?.let(api::createUrl) + if (wasSuccessful) return@withContext } - if (mediaUrl.isNullOrBlank()) { - _state.update { - it.copy( - loading = - LoadingState.Error( - "Unable to get media URL from the server. Do you have permission to view and/or transcode?", - ), + } + + Timber.i( + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", + itemId, + audioIndex, + subtitleIndex, + enableDirectPlay, + enableDirectStream, + positionMs, + ) + + val maxBitrate = + preferences.appPreferences.playbackPreferences.maxBitrate + .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE + val response by + api.mediaInfoApi + .getPostedPlaybackInfo( + itemId, + PlaybackInfoDto( + startTimeTicks = null, + deviceProfile = + if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { + deviceProfileService.getOrCreateDeviceProfile( + preferences.appPreferences, + serverRepository.currentServer?.serverVersion, + ) + } else { + mpvDeviceProfile + }, + maxAudioChannels = null, + audioStreamIndex = audioIndex, + subtitleStreamIndex = subtitleIndex, + mediaSourceId = sourceId, + alwaysBurnInSubtitleWhenTranscoding = false, + maxStreamingBitrate = maxBitrate.toInt(), + enableDirectPlay = enableDirectPlay, + enableDirectStream = enableDirectStream, + allowVideoStreamCopy = enableDirectStream, + allowAudioStreamCopy = enableDirectStream, + enableTranscoding = true, + autoOpenLiveStream = true, + ), ) - } + if (response.errorCode != null) { + Timber.e("Error in PostedPlaybackInfo: %s", response.errorCode) + _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } return@withContext } - val transcodeType = - when { -// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY - source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - - source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - - source.supportsTranscoding -> PlayMethod.TRANSCODE - - else -> throw Exception("No supported playback method") - } - Timber.i("Playback decision for $itemId: $transcodeType") - - val externalSubtitleCount = source.externalSubtitlesCount - - val externalSubtitle = - source.findExternalSubtitle(subtitleIndex)?.let { - it.deliveryUrl?.let { deliveryUrl -> - var flags = 0 - if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) - if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) - MediaItem.SubtitleConfiguration - .Builder( - api.createUrl(deliveryUrl).toUri(), - ).setId("e:${it.index}") - .setMimeType(subtitleMimeTypes[it.codec]) - .setLanguage(it.language) - .setLabel(it.title) - .setSelectionFlags(flags) - .build() - } - } - - Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle") - - val mediaItem = - MediaItem - .Builder() - .setMediaId(itemId.toString()) - .setMediaMetadata( - item.toMediaMetadata( - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - useSeriesForPrimary = true, - ), - ), - ).setUri(mediaUrl.toUri()) - .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) - .apply { - when (source.container) { - Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) - Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + val source = response.mediaSources.firstOrNull() + source?.let { source -> + val mediaUrl = + if (source.supportsDirectPlay) { + if (source.isRemote && source.path.isNotNullOrBlank()) { + Timber.i("Playback is remote for source: %s", source.id) + source.path + } else { + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = source.id, + static = true, + tag = source.eTag, + playSessionId = response.playSessionId, + ) } - }.build() - - val playback = - CurrentPlayback( - item = item, - tracks = listOf(), - backend = currentPlayer.value!!.backend, - playMethod = transcodeType, - playSessionId = response.playSessionId, - liveStreamId = source.liveStreamId, - mediaSourceInfo = source, - ) - - preferences.appPreferences.playbackPreferences.let { prefs -> - source.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.VIDEO } - ?.let { stream -> - refreshRateService.changeRefreshRate( - stream = stream, - switchRefreshRate = prefs.refreshRateSwitching, - switchResolution = prefs.resolutionSwitching, + } else if (source.supportsDirectStream) { + source.transcodingUrl?.let(api::createUrl) + } else { + source.transcodingUrl?.let(api::createUrl) + } + if (mediaUrl.isNullOrBlank()) { + _state.update { + it.copy( + loading = + LoadingState.Error( + "Unable to get media URL from the server. Do you have permission to view and/or transcode?", + ), ) } - } - withContext(Dispatchers.Main) { - // TODO, don't need to release & recreate when switching streams - this@PlaybackViewModel.activityListener?.let { - it.release() - player.removeListener(it) + return@withContext } + val transcodeType = + when { +// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY + source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - val playbackItemState = PlaybackItemState(playback, currentItemPlayback) - val activityListener = - TrackActivityPlaybackListener( - api = api, - player = player, - getState = { playbackItemState }, - ) - player.addListener(activityListener) - this@PlaybackViewModel.activityListener = activityListener + source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - _state.update { - it.copy( - loading = LoadingState.Success, - currentPlayback = playback, - ) - } - player.setMediaItem( - mediaItem, - positionMs, + source.supportsTranscoding -> PlayMethod.TRANSCODE + + else -> throw Exception("No supported playback method") + } + Timber.i("Playback decision for $itemId: $transcodeType") + + val externalSubtitleCount = source.externalSubtitlesCount + + val externalSubtitle = + source.findExternalSubtitle(subtitleIndex)?.let { + Timber.v("Using externally delivered subtitle tracks %s", subtitleIndex) + it.deliveryUrl?.let { deliveryUrl -> + var flags = 0 + if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) + if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) + MediaItem.SubtitleConfiguration + .Builder( + api.createUrl(deliveryUrl).toUri(), + ).setId("e:${it.index}") + .setMimeType(subtitleMimeTypes[it.codec]) + .setLanguage(it.language) + .setLabel(it.title) + .setSelectionFlags(flags) + .build() + } + } + + Timber.v( + "subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle", ) - if (audioIndex != null || subtitleIndex != null) { - val onTracksChangedListener = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - Timber.v("onTracksChanged: $tracks") - if (tracks.groups.isNotEmpty()) { - val result = - TrackSelectionUtils.createTrackSelections( - player.trackSelectionParameters, - player.currentTracks, - currentPlayer.value!!.backend, - source.supportsDirectPlay, - audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, - subtitleIndex, - source, - ) - Timber.v("onTracksChanged: %s", result) - if (result.bothSelected) { - player.trackSelectionParameters = - result.trackSelectionParameters - player.removeListener(this) - } - viewModelScope.launchIO { loadSubtitleDelay() } - } + + val mediaItem = + MediaItem + .Builder() + .setMediaId(itemId.toString()) + .setMediaMetadata( + item.toMediaMetadata( + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + useSeriesForPrimary = true, + ), + ), + ).setUri(mediaUrl.toUri()) + .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) + .apply { + when (source.container) { + Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) + Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + } + }.build() + + val playback = + CurrentPlayback( + item = item, + tracks = listOf(), + backend = currentPlayer.value!!.backend, + playMethod = transcodeType, + playSessionId = response.playSessionId, + liveStreamId = source.liveStreamId, + mediaSourceInfo = source, + audioIndex = audioIndex ?: TrackIndex.DISABLED, + subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + ) + + preferences.appPreferences.playbackPreferences.let { prefs -> + source.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { stream -> + refreshRateService.changeRefreshRate( + stream = stream, + switchRefreshRate = prefs.refreshRateSwitching, + switchResolution = prefs.resolutionSwitching, + ) + } + } + withContext(WholphinDispatchers.Main) { + // TODO, don't need to release & recreate when switching streams + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } + + val playbackItemState = PlaybackItemState(playback) + val activityListener = + TrackActivityPlaybackListener( + api = api, + player = player, + getState = { playbackItemState }, + ) + player.addListener(activityListener) + this@PlaybackViewModel.activityListener = activityListener + + _state.update { + it.copy( + loading = LoadingState.Success, + currentPlayback = playback, + ) + } + player.setMediaItem( + mediaItem, + positionMs, + ) + val onFailure: () -> Unit = { + if (player is MpvPlayer && externalSubtitle != null) { + // MpvPlayer may change tracks more than once to add external subtitles + trackChangeListener?.let { player.addListener(it) } + } else { + viewModelScope.launchIO { + changeStreams( + item = item, + sourceId = sourceId, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + positionMs = positionMs, + enableDirectPlay = false, + enableDirectStream = true, + ) } } - player.addListener(onTracksChangedListener) + } + val onTracksChangedListener = + if (transcodeType == PlayMethod.DIRECT_PLAY && (audioIndex != null || subtitleIndex != null)) { + TracksChangedListener( + player = player, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = source, + onFailure = onFailure, + ) + } else if (externalSubtitle != null) { + TracksChangedListener( + player = player, + audioIndex = null, // Do not manually activate audio + subtitleIndex = subtitleIndex, + source = source, + onFailure = onFailure, + ) + } else { + null + } + onTracksChangedListener?.let { + player.addListener(it) + this@PlaybackViewModel.trackChangeListener = it + } + + if (subtitleIndex != null) { + viewModelScope.launchIO { loadSubtitleDelay() } + } } } } - } /** * If direct playing, can try to switch tracks without playback restarting @@ -836,26 +880,35 @@ class PlaybackViewModel @OptIn(UnstableApi::class) private suspend fun changeStreamsDirectPlay( currentPlayback: CurrentPlayback, - currentItemPlayback: ItemPlayback, audioIndex: Int?, subtitleIndex: Int?, - userInitiated: Boolean, ): Boolean = - withContext(Dispatchers.IO) { - // TODO there's probably no reason why we can't add external subtitles? + withContext(WholphinDispatchers.IO) { Timber.v("changeStreams direct play") + // TODO Better way to handle unsupported types in general is needed + // This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3 + if (preferences.appPreferences.experimentalPreferences.enabled { preferAc3Surround } && audioIndex != null) { + currentPlayback.mediaSourceInfo.mediaStreams + .orEmpty() + .firstOrNull { it.index == audioIndex } + ?.let { + if (it.channels.gt(2) && it.codec != Codec.Audio.AC3) { + // User wants to transcode audio into AC3 + return@withContext false + } + } + } + val source = currentPlayback.mediaSourceInfo val externalSubtitle = source.findExternalSubtitle(subtitleIndex) if (externalSubtitle == null) { val result = - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { TrackSelectionUtils.createTrackSelections( onMain { player.trackSelectionParameters }, onMain { player.currentTracks }, - currentPlayer.value!!.backend, - true, audioIndex, subtitleIndex, source, @@ -863,34 +916,12 @@ class PlaybackViewModel } if (result.bothSelected) { onMain { player.trackSelectionParameters = result.trackSelectionParameters } - // TODO lots of duplicate code in this block Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") - val itemPlayback = - currentItemPlayback.copy( - sourceId = source.id?.toUUIDOrNull(), - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index - subtitleIndex = - if (currentItemPlayback.subtitleIndex < 0) { - currentItemPlayback.subtitleIndex - } else { - subtitleIndex ?: TrackIndex.DISABLED - }, - ) - if (userInitiated) { - viewModelScope.launchIO { - Timber.v("Saving user initiated item playback: %s", itemPlayback) - val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - _state.update { it.copy(currentItemPlayback = updated) } - } - } else { - _state.update { it.copy(currentItemPlayback = itemPlayback) } - } _state.update { it.copy( currentPlayback = (it.currentPlayback ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), + tracks = checkForSupport(onMain { player.currentTracks }), ), ) } @@ -905,78 +936,92 @@ class PlaybackViewModel fun changeAudioStream(index: Int) { viewModelScope.launchIO { - Timber.d("Changing audio track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = currentItem.item, - itemPlayback = state.value.currentItemPlayback!!, - trackIndex = index, - type = MediaStreamType.AUDIO, - ) - _state.update { it.copy(currentItemPlayback = itemPlayback) } - - // Resolve ONLY_FORCED to actual track based on new audio language - val source = state.value.currentPlayback?.mediaSourceInfo - val resolvedSubtitleIndex = - if (source != null) { - streamChoiceService.resolveSubtitleIndex( - source = source, - audioStreamIndex = index, - seriesId = currentItem.item.data.seriesId, - subtitleIndex = itemPlayback.subtitleIndex, - prefs = preferences, + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing audio track to %s", index) + saveTrackSelection(index, MediaStreamType.AUDIO) + _state.update { + it.copy( + currentPlayback = it.currentPlayback?.copy(audioIndex = index), ) - } else { - itemPlayback.subtitleIndex.takeIf { it >= 0 } } - changeStreams( - currentItem.item, - itemPlayback, - index, - resolvedSubtitleIndex, - onMain { player.currentPosition }, - true, - ) + // Resolve ONLY_FORCED to actual track based on new audio language + val resolvedSubtitleIndex = + streamChoiceService.resolveSubtitleIndex( + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = index, + seriesId = currentItem.item.data.seriesId, + subtitleIndex = currentPlayback.subtitleIndex, + prefs = preferences, + ) + + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = index, + subtitleIndex = resolvedSubtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + ) + } else { + Timber.w("Trying to change audio, but currentPlayback is null") + } } } fun changeSubtitleStream(index: Int): Job = viewModelScope.launchIO { - Timber.d("Changing subtitle track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = currentItem.item, - itemPlayback = state.value.currentItemPlayback!!, - trackIndex = index, - type = MediaStreamType.SUBTITLE, - ) - _state.update { it.copy(currentItemPlayback = itemPlayback) } + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing subtitle track to %s", index) + saveTrackSelection(index, MediaStreamType.SUBTITLE) + _state.update { + it.copy( + currentPlayback = it.currentPlayback?.copy(subtitleIndex = index), + ) + } - // Resolve ONLY_FORCED to actual track index for playback - val source = state.value.currentPlayback?.mediaSourceInfo - val resolvedIndex = - if (source != null) { + // Resolve ONLY_FORCED to actual track index for playback + val resolvedIndex = streamChoiceService.resolveSubtitleIndex( - source = source, - audioStreamIndex = itemPlayback.audioIndex, + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = currentPlayback.audioIndex, seriesId = currentItem.item.data.seriesId, subtitleIndex = index, prefs = preferences, ) - } else { - index.takeIf { it >= 0 } - } - changeStreams( - currentItem.item, - itemPlayback, - itemPlayback.audioIndex, - resolvedIndex, - onMain { player.currentPosition }, - true, + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = resolvedIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + ) + } else { + Timber.w("Trying to change subtitle, but currentPlayback is null") + } + } + + internal suspend fun saveTrackSelection( + trackIndex: Int, + type: MediaStreamType, + ) { + val itemPlayback = + itemPlaybackRepository.saveTrackSelection( + item = currentItem.item, + itemPlayback = state.value.currentItemPlayback, + trackIndex = trackIndex, + type = type, + ) + _state.update { + it.copy( + currentItemPlayback = itemPlayback, ) } + } private suspend fun prefetchTrickplay( duration: Duration, @@ -1001,7 +1046,11 @@ class PlaybackViewModel fun getTrickplayUrl( index: Int, trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo, - mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId, + mediaSourceId: UUID? = + state.value.currentPlayback + ?.mediaSourceInfo + ?.id + ?.toUUIDOrNull(), ): String? = trickPlayInfo?.let { val itemId = currentItem.id @@ -1017,21 +1066,33 @@ class PlaybackViewModel if (playbackState == Player.STATE_ENDED) { Timber.v("Playback state is STATE_ENDED") viewModelScope.launchDefault { - when (val nextItem = state.value.playlist.peek()) { + when (val nextItem = state.value.nextItem()) { is PlaylistItem.Intro -> { Timber.v("Next item is intro, so playing immediately") playNextUp() } is PlaylistItem.Media -> { - if (currentItem is PlaylistItem.Intro) { - Timber.v("Current item is intro, so playing next up immediately") - playNextUp() - } else if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { - Timber.v("Setting next up to ${nextItem.id}") - _state.update { it.copy(nextUp = nextItem.item) } - } else { - controllerViewState.showControls() + val prefs = preferences.appPreferences.playbackPreferences + when { + currentItem is PlaylistItem.Intro -> { + Timber.v("Current item is intro, so playing next up immediately") + playNextUp() + } + + prefs.showNextUpWhen == ShowNextUpWhen.NEXT_UP_NEVER && !prefs.autoPlayNext -> { + Timber.v("Never show or auto play next up, returning") + navigationManager.goBack() + } + + prefs.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER -> { + Timber.v("Setting next up to ${nextItem.id}") + _state.update { it.copy(nextUp = nextItem.item) } + } + + else -> { + controllerViewState.showControls() + } } } @@ -1045,7 +1106,8 @@ class PlaybackViewModel } // Variables for tracking segment state - private var segmentJob: Job? = null + // Exposed for testing + internal var segmentJob: Job? = null private val autoSkippedSegments = mutableSetOf<UUID>() private val outroShownSegments = mutableSetOf<UUID>() @@ -1093,14 +1155,14 @@ class PlaybackViewModel currentSegment.type, ) } - val playlist = state.value.playlist + val state = state.value if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - playlist.hasNext() && + state.hasNext && outroShownSegments.add(currentSegment.id) ) { - val nextItem = playlist.peek() + val nextItem = state.nextItem() if (nextItem is PlaylistItem.Media) { Timber.v("Setting next up during outro to ${nextItem?.id}") _state.update { it.copy(nextUp = nextItem.item) } @@ -1115,7 +1177,7 @@ class PlaybackViewModel MediaSegmentType.INTRO -> prefs.skipIntros MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { val newSegment = when (behavior) { SkipSegmentBehavior.AUTO_SKIP -> { @@ -1218,30 +1280,50 @@ class PlaybackViewModel } fun playNextUp() { - state.value.playlist.let { - if (it.hasNext()) { - viewModelScope.launchDefault { + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + if (state.hasNext) { cancelUpNextEpisode() - val item = it.getAndAdvance() - val played = play(item, 0) - if (!played) { - playNextUp() - } + val nextIndex = state.playlistIndex + 1 + val item = state.playlist.items[nextIndex] + _state.update { it.copy(playlistIndex = nextIndex) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(item, 0) + if (!played) { + playNextUp() + } + } + } else { + Timber.w("Attempting to play next, but there are no more items") + return@launchDefault } } } } fun playPrevious() { - state.value.playlist.let { - if (it.hasPrevious()) { - viewModelScope.launchDefault { + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + if (state.hasPrevious) { cancelUpNextEpisode() - val item = it.getPreviousAndReverse() - val played = play(item, 0) - if (!played) { - playPrevious() - } + val previousIndex = state.playlistIndex - 1 + val item = state.playlist.items[previousIndex] + _state.update { it.copy(playlistIndex = previousIndex) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(item, 0) + if (!played) { + playPrevious() + } + } + } else { + Timber.w("Attempting to play previous, but there is none") + return@launchDefault } } } @@ -1252,16 +1334,24 @@ class PlaybackViewModel } fun playItemInPlaylist(item: BaseItem) { - state.value.playlist.let { playlist -> - viewModelScope.launchIO { - val toPlay = playlist.advanceTo(item.id) - if (toPlay != null) { - val played = play(toPlay, 0) - if (!played) { - playNextUp() - } + viewModelScope.launchDefault { + playlistMutex.withLock { + val state = state.value + val index = state.playlist.items.indexOfFirst { it.id == item.id } + if (index in state.playlist.items.indices) { + val toPlay = state.playlist.items[index] + _state.update { it.copy(playlistIndex = index) } + playlistJob?.cancel() + playlistJob = + viewModelScope.launchDefault { + val played = play(toPlay, 0) + if (!played) { + playNextUp() + } + } } else { - // TODO + Timber.w("Item not found in playlist %s", item.id) + return@launchDefault } } } @@ -1281,7 +1371,7 @@ class PlaybackViewModel override fun onPlayerError(error: PlaybackException) { Timber.e(error, "Playback error") - viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + viewModelScope.launch(WholphinDispatchers.Main + ExceptionHandler()) { state.value.currentPlayback?.let { when (it.playMethod) { PlayMethod.TRANSCODE -> { @@ -1298,18 +1388,20 @@ class PlaybackViewModel PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") - val currentItemPlayback = state.value.currentItemPlayback!! + val currentPlayback = state.value.currentPlayback + if (currentPlayback == null) { + Timber.w("Playback error, currentPlayback is null") + } changeStreams( - currentItem.item, - currentItemPlayback, - currentItemPlayback.audioIndex, - currentItemPlayback.subtitleIndex, - player.currentPosition, - false, + item = currentItem.item, + sourceId = currentPlayback?.mediaSourceInfo?.id, + audioIndex = currentPlayback?.audioIndex, + subtitleIndex = currentPlayback?.subtitleIndex, + positionMs = player.currentPosition, enableDirectPlay = false, enableDirectStream = false, ) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { player.prepare() player.play() } @@ -1330,7 +1422,7 @@ class PlaybackViewModel .subscribe<PlaystateMessage>() .onEach { message -> message.data?.let { - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { when (it.command) { PlaystateCommand.STOP -> { release() @@ -1379,13 +1471,15 @@ class PlaybackViewModel } } } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") }.launchIn(viewModelScope) /** * Atomically update [currentMediaInfo] */ internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.Default) { _state.update { it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) } @@ -1508,16 +1602,16 @@ class PlaybackViewModel } suspend fun loadSubtitleDelay() { - state.value.currentItemPlayback?.let { + state.value.currentPlayback?.let { if (it.subtitleIndexEnabled) { val result = - itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex) + itemPlaybackRepository.getTrackModifications(it.item.id, it.subtitleIndex) if (result != null) { Timber.v( "Loading subtitle delay %s for track=%s, itemId=%s", result.delayMs, it.subtitleIndex, - it.itemId, + it.item.id, ) updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } } @@ -1545,6 +1639,21 @@ class PlaybackViewModel } } + fun updateDensity(density: Density) { + Timber.d("Density changed") + viewModelScope.launchDefault { + val availableCommands = onMain { player.availableCommands } + if (availableCommands.contains(Player.COMMAND_PREPARE) && player is MpvPlayer) { + Timber.i("Applying density change for subtitle config to MPV") + val configuration = context.resources.configuration + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + configuration, + density, + ) + } + } + } + override fun onBandwidthEstimate( eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 5892b01a..c08759c0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -7,10 +7,12 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.services.getPreferredLanguage +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -19,7 +21,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds sealed interface SubtitleSearchStatus { data object Inactive : SubtitleSearchStatus @@ -41,7 +45,17 @@ sealed interface SubtitleSearchStatus { /** * Trigger a search for subtitles in the given language for the currently playing media */ -fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { +fun PlaybackViewModel.searchForSubtitles(language: String? = null) { + val language = + language ?: getPreferredLanguage( + MediaStreamType.SUBTITLE, + preferences, + serverRepository + .currentUserDto + ?.configuration, + )?.takeIf { it.isNotNullOrBlank() } + ?: Locale.current.language + subtitleSearchState.update { it.copy( status = SubtitleSearchStatus.Searching, @@ -50,16 +64,17 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu } viewModelScope.launchIO { try { - state.value.currentItemPlayback?.itemId?.let { - Timber.v("Searching for remote subtitles for %s", it) + state.value.currentPlayback?.itemId?.let { itemId -> + Timber.v("Searching for remote subtitles for %s", itemId) val results = api.subtitleApi .searchRemoteSubtitles( - itemId = it, + itemId = itemId, language = language, ).content .sortedWith( - compareByDescending<RemoteSubtitleInfo> { it.communityRating } + compareByDescending<RemoteSubtitleInfo> { it.isHashMatch } + .thenByDescending { it.communityRating } .thenByDescending { it.downloadCount }, ) subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } @@ -92,21 +107,19 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) } viewModelScope.launchIO { try { - state.value.currentItemPlayback?.let { + state.value.currentPlayback?.let { currentPlayback -> Timber.v( "Downloading remote subtitles for itemId=%s, sourceId=%s: %s", - it.itemId, - it.sourceId, + currentPlayback.itemId, + currentPlayback.sourceId, subtitleId, ) api.subtitleApi.downloadRemoteSubtitles( - itemId = it.sourceId ?: it.itemId, + itemId = currentPlayback.sourceId ?: currentPlayback.itemId, subtitleId = subtitleId, ) - val currentSource = state.value.currentPlayback?.mediaSourceInfo val currentSubtitleStreams = - currentSource - ?.mediaStreams + currentPlayback.mediaSourceInfo.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() val externalPaths = currentSubtitleStreams.map { it.path } @@ -119,14 +132,16 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- - delay(1500) - val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content) + delay(1500.milliseconds) + val base = + BaseItem(api.userLibraryApi.getItem(itemId = currentPlayback.itemId).content) currentItem = when (currentItem) { is PlaylistItem.Intro -> PlaylistItem.Intro(base) is PlaylistItem.Media -> PlaylistItem.Media(base) } - mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it) + mediaSource = + base.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == currentPlayback.sourceId } if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -157,33 +172,48 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( stream.isExternal && stream.path !in externalPaths } if (newStream != null) { - var audioIndex = it?.audioIndex - if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { - // User has picked a specific audio track - // Since, now adding a new external subtitle track, need to adjust the audio index as well + var audioIndex = currentPlayback.audioIndex + if (audioIndex != TrackIndex.UNSPECIFIED && audioIndex >= newStream.index) { + // User has previously picked a specific audio track + // If the new external subtitle track was added before the audio track, need to adjust the audio index as well Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 + state.value.currentItemPlayback?.let { currentItemPlayback -> + if (currentItemPlayback.audioIndex != TrackIndex.UNSPECIFIED) { + Timber.d("User has a previously saved audio index that needs to be updated") + saveTrackSelection(audioIndex, MediaStreamType.AUDIO) + } + } } + saveTrackSelection(newStream.index, MediaStreamType.SUBTITLE) updateCurrentMedia { it.copy( - subtitleStreams = - subtitlesStreams.map { - SimpleMediaStream.from(context.resources, it, true) - }, + sourceId = mediaSource.id, + audioStreams = getAudioStreams(mediaSource), + subtitleStreams = getSubtitleStreams(mediaSource), ) } + updateCurrentPlayback { + it?.copy( + item = currentItem.item, + mediaSourceInfo = mediaSource, + audioIndex = audioIndex, + subtitleIndex = newStream.index, + ) + } + this@downloadAndSwitchSubtitles.changeStreams( - currentItem.item, - it, - audioIndex, - newStream.index, - onMain { player.currentPosition }, - true, + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = audioIndex, + subtitleIndex = newStream.index, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, ) } } subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { if (wasPlaying) { player.play() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index cfbbe6b7..7b377399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -7,13 +7,11 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import timber.log.Timber -import kotlin.math.max /** * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] @@ -23,202 +21,159 @@ object TrackSelectionUtils { fun createTrackSelections( trackSelectionParams: TrackSelectionParameters, tracks: Tracks, - playerBackend: PlayerBackend, - supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val embeddedSubtitleCount = source.embeddedSubtitleCount - val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = trackSelectionParams.buildUpon() - val groups = tracks.groups - val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null - if (subtitleIsExternal || supportsDirectPlay) { - val chosenTrack = - if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0..<group.mediaTrackGroup.length) - .mapNotNull { - group.getTrackFormat(it).id - }.any { it.endsWith("e:$subtitleIndex") } - } - } else { - val actualEmbeddedCount = - groups - .filter { group -> - group.type == C.TRACK_TYPE_TEXT && - (0..<group.mediaTrackGroup.length) - .mapNotNull { - group.getTrackFormat(it).id - }.none { it.contains("e:") } - }.size - val indexToFind = - calculateIndexToFind( - subtitleIndex, - MediaStreamType.SUBTITLE, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - subtitleIsExternal, - actualEmbeddedCount, - source, - ) - Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track") - // subtitleIndex - externalSubtitleCount + 1 - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0..<group.mediaTrackGroup.length) - .filter { - if (subtitleIsExternal) { - group.getTrackFormat(0).id?.contains("e:") == true - } else { - group.getTrackFormat(0).id?.contains("e:") == false - } - }.map { - group.getTrackFormat(it).idAsInt - }.contains(indexToFind) - } + val chosenTrack = + if (subtitleIsExternal) { + tracks.groups.firstOrNull { group -> + group.type == C.TRACK_TYPE_TEXT && group.isExternal } - - Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") - chosenTrack?.let { + } else { + val playerIndex = + getPlayerIndex(subtitleIndex, source, MediaStreamType.SUBTITLE) + if (playerIndex != null) { + tracks.groups + .filter { group -> + group.type == C.TRACK_TYPE_TEXT && + group.length >= 1 && + !group.isExternal + } + // TODO why are exoplayer tracks out of order sometimes? + .sortedById() + .getOrNull(playerIndex) + } else { + null + } + } + when { + chosenTrack != null && chosenTrack.isSupported -> { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - chosenTrack != null - } else { - false } } else { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - - true + TrackSelected.SELECTED } + val audioSelected = - if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) { - val indexToFind = - calculateIndexToFind( - audioIndex, - MediaStreamType.AUDIO, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - false, - null, - source, - ) + if (audioIndex != null && audioIndex >= 0) { + val playerIndex = getPlayerIndex(audioIndex, source, MediaStreamType.AUDIO) val chosenTrack = - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_AUDIO && group.isSupported && - (0..<group.mediaTrackGroup.length) - .map { - group.getTrackFormat(it).idAsInt - }.contains(indexToFind) + if (playerIndex != null) { + tracks.groups + .filter { group -> + group.type == C.TRACK_TYPE_AUDIO && group.length >= 1 + } + // TODO why are exoplayer tracks out of order sometimes? + .sortedById() + .getOrNull(playerIndex) + } else { + null + } + when { + chosenTrack != null && chosenTrack.isSupported -> { + paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .setOverrideForType( + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), + ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack") - chosenTrack?.let { - paramsBuilder - .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) - .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), - ) } - chosenTrack != null } else { - true + TrackSelected.SELECTED } return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) } - /** - * Maps the server provided index to the track index based on the [PlayerBackend] and other stream information - */ - private fun calculateIndexToFind( + private fun getPlayerIndex( serverIndex: Int, - type: MediaStreamType, - playerBackend: PlayerBackend, - embeddedSubtitleCount: Int, - externalSubtitleCount: Int, - subtitleIsExternal: Boolean, - actualEmbeddedCount: Int?, source: MediaSourceInfo, - ): Int = - when (playerBackend) { - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - serverIndex - externalSubtitleCount + 1 + type: MediaStreamType, + ): Int? { + val playerIndex = + source.mediaStreams + .orEmpty() + .filter { it.type == type } + .let { + if (type == MediaStreamType.SUBTITLE) { + it.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS } + } else { + it + } + }.indexOfFirstOrNull { it.index == serverIndex } + return playerIndex + } + + val Tracks.Group.trackFormats: List<Format> + @OptIn(UnstableApi::class) + get() = + (0..<mediaTrackGroup.length) + .mapNotNull { + getTrackFormat(it) + } + + private val Tracks.Group.isExternal: Boolean + get() = + trackFormats.any { + it.id?.contains(":e:") == true } - // TODO MPV could use literal indexes because they are stored in the track format ID - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - when (type) { - MediaStreamType.VIDEO -> { - serverIndex - externalSubtitleCount + 1 - } - - MediaStreamType.AUDIO -> { - val videoStreamsBeforeAudioCount = - source.mediaStreams - .orEmpty() - .indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount - serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 - } - - MediaStreamType.SUBTITLE -> { - if (subtitleIsExternal) { - // Need to account for the actual embedded count because if the library - // disables embedded subtitles, they still exist in the direct played file, - // but not included in the MediaStreams list - serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1 + private fun Iterable<Tracks.Group>.sortedById(): List<Tracks.Group> = + mapNotNull { track -> + if (track.isExternal) { + // Should be filtered out before calling this though + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) + } else { + track.trackFormats[0] + .id + ?.split(":", "/") + ?.map { it.toIntOrNull() ?: Int.MAX_VALUE } + ?.let { + if (it.size >= 2) { + track to it + } else if (it.isEmpty()) { + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) } else { - val videoStreamCount = source.videoStreamCount - val audioStreamCount = source.audioStreamCount - serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 + track to listOf(it[0], Int.MAX_VALUE) } } - - else -> { - throw UnsupportedOperationException("Cannot calculate index for $type") - } - } } - - PlayerBackend.EXTERNAL_PLAYER -> { - throw IllegalStateException("Cannot calculate tracks external playback") - } - } + }.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] }) + .map { it.first } } -val Format.idAsInt: Int? - @OptIn(UnstableApi::class) - get() = - id?.let { - if (it.contains(":")) { - it.split(":").last().toIntOrNull() - } else { - it.toIntOrNull() - } - } - /** * Returns the number of external subtitle streams there are */ @@ -270,8 +225,18 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = */ data class TrackSelectionResult( val trackSelectionParameters: TrackSelectionParameters, - val audioSelected: Boolean, - val subtitleSelected: Boolean, + val audio: TrackSelected, + val subtitle: TrackSelected, ) { - val bothSelected: Boolean = audioSelected && subtitleSelected + val audioSelected: Boolean get() = audio == TrackSelected.SELECTED + + val subtitleSelected: Boolean get() = subtitle == TrackSelected.SELECTED + + val bothSelected: Boolean get() = audioSelected && subtitleSelected +} + +enum class TrackSelected { + SELECTED, + NOT_FOUND, + UNSUPPORTED, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt new file mode 100644 index 00000000..c5192153 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt @@ -0,0 +1,41 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaSourceInfo +import timber.log.Timber + +class TracksChangedListener( + val player: Player, + val audioIndex: Int?, + val subtitleIndex: Int?, + val source: MediaSourceInfo, + val onFailure: () -> Unit, +) : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + Timber.v("onTracksChanged: $tracks") + if (tracks.groups.isNotEmpty()) { + val result = + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, + audioIndex, + subtitleIndex, + source, + ) + Timber.v("onTracksChanged: %s", result) + player.removeListener(this) + if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters + } else { + // Fall back to transcoding + Timber.w( + "Failed to select tracks, falling back to transcoding: %s", + result, + ) + onFailure.invoke() + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt index 30254061..fa32950b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt @@ -31,7 +31,6 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.components.HiddenFocusBox import com.github.damontecres.wholphin.ui.ifElse @@ -44,8 +43,7 @@ fun ChapterRowOverlay( player: Player, controllerViewState: ControllerViewState, chapters: List<Chapter>, - playlist: Playlist, - aspectRatio: Float, + hasNext: Boolean, onChangeState: (OverlayViewState) -> Unit, modifier: Modifier = Modifier, ) { @@ -116,10 +114,7 @@ fun ChapterRowOverlay( if (isFocused) controllerViewState.pulseControls() } ChapterCard( - name = chapter.name, - position = chapter.position, - imageUrl = chapter.imageUrl, - aspectRatio = aspectRatio, + chapter = chapter, onClick = { player.seekTo(chapter.position.inWholeMilliseconds) controllerViewState.hideControls() @@ -147,7 +142,7 @@ fun ChapterRowOverlay( ) } } - if (playlist.hasNext()) { + if (hasNext) { Text( text = stringResource(R.string.queue), style = MaterialTheme.typography.titleLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt new file mode 100644 index 00000000..0a3ed634 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import org.jellyfin.sdk.model.api.TrickplayInfo + +/** + * The compact overlay shown during D-Pad seeking in trickplay mode. + */ +@Composable +fun DpadSeekOverlay( + player: Player, + seekPositionMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + val durationMs = player.duration.coerceAtLeast(0L) + val seekProgressPercent = + if (durationMs > 0) { + (seekPositionMs.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + val bufferedProgress = + if (durationMs > 0) { + (player.bufferedPosition.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + TrickplayPreview( + seekProgressMs = seekPositionMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent(xPercentage = seekProgressPercent) + .padding(bottom = 8.dp), + ) + } + StaticSeekBarImpl( + progress = seekProgressPercent, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + SeekTimecodes( + positionMs = seekPositionMs, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt index 528b6546..2909e666 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -32,7 +33,7 @@ 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.getTimeFormatter +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType import kotlinx.coroutines.delay @@ -199,8 +200,9 @@ fun Controller( ) } + val context = LocalContext.current var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(player) { + LaunchedEffect(player, context) { while (isActive) { val remaining = (player.duration - player.currentPosition) @@ -208,12 +210,12 @@ fun Controller( .toLong() .milliseconds val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = getTimeFormatter().format(endTime) + endTimeStr = formatTime(context, endTime) delay(1.seconds) } } Text( - text = "Ends $endTimeStr", + text = stringResource(R.string.ends_at, endTimeStr), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index 820e634f..ba77d8b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -43,6 +44,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -70,6 +72,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.ControllerViewState import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType @@ -189,6 +192,7 @@ fun PlaybackControls( onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), ) + PlaybackButtons( player = player, initialFocusRequester = initialFocusRequester, @@ -202,6 +206,7 @@ fun PlaybackControls( skipBackOnResume = skipBackOnResume, modifier = Modifier.align(Alignment.Center), ) + Row( modifier = Modifier.align(Alignment.CenterEnd), ) { @@ -270,27 +275,53 @@ fun SeekBar( seekBack = seekBack, seekForward = seekForward, ) - Row( + SeekTimecodes( + positionMs = position, + durationMs = player.duration, modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +fun SeekTimecodes( + positionMs: Long, + durationMs: Long, + modifier: Modifier = Modifier, +) { + val currentLayoutDirection = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, ) { - val remaining = ((player.duration - position) / 1000).seconds - Text( - text = (position / 1000).seconds.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - Text( - text = "-$remaining", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) + val resources = LocalResources.current + val positionSec = positionMs / 1000 + val remainingSec = (durationMs - positionMs) / 1000 + val positionText = + remember(positionSec) { resources.formatDuration(positionSec.seconds) } + val remainingText = + remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } + CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) { + Text( + text = positionText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + } + CompositionLocalProvider(LocalLayoutDirection provides currentLayoutDirection) { + Text( + text = remainingText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + } } } } @@ -370,61 +401,62 @@ fun PlaybackButtons( seekForward: Duration, modifier: Modifier = Modifier, ) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - Row( - modifier = modifier.focusGroup(), - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - ) { - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_skip_previous_24 else R.drawable.baseline_skip_next_24, - onClick = { - onControllerInteraction.invoke() - onPlaybackActionClick.invoke(PlaybackAction.Previous) - }, - enabled = previousEnabled, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_fast_rewind_24 else R.drawable.baseline_fast_forward_24, - onClick = { - onControllerInteraction.invoke() - player.seekBack(seekBack) - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - modifier = Modifier.focusRequester(initialFocusRequester), - iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, - onClick = { - onControllerInteraction.invoke() - if (showPlay) { - player.play() - skipBackOnResume?.let { - player.seekBack(it) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = modifier.focusGroup(), + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + ) { + PlaybackButton( + iconRes = R.drawable.baseline_skip_previous_24, + onClick = { + onControllerInteraction.invoke() + onPlaybackActionClick.invoke(PlaybackAction.Previous) + }, + enabled = previousEnabled, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_fast_rewind_24, + onClick = { + onControllerInteraction.invoke() + player.seekBack(seekBack) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + modifier = Modifier.focusRequester(initialFocusRequester), + iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, + onClick = { + onControllerInteraction.invoke() + if (showPlay) { + player.play() + skipBackOnResume?.let { + player.seekBack(it) + } + } else { + player.pause() } - } else { - player.pause() - } - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_fast_forward_24 else R.drawable.baseline_fast_rewind_24, - onClick = { - onControllerInteraction.invoke() - player.seekForward(seekForward) - }, - onControllerInteraction = onControllerInteraction, - ) - PlaybackButton( - iconRes = if (isLtr) R.drawable.baseline_skip_next_24 else R.drawable.baseline_skip_previous_24, - onClick = { - onControllerInteraction.invoke() - onPlaybackActionClick.invoke(PlaybackAction.Next) - }, - enabled = nextEnabled, - onControllerInteraction = onControllerInteraction, - ) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_fast_forward_24, + onClick = { + onControllerInteraction.invoke() + player.seekForward(seekForward) + }, + onControllerInteraction = onControllerInteraction, + ) + PlaybackButton( + iconRes = R.drawable.baseline_skip_next_24, + onClick = { + onControllerInteraction.invoke() + onPlaybackActionClick.invoke(PlaybackAction.Next) + }, + enabled = nextEnabled, + onControllerInteraction = onControllerInteraction, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt index 1420ffb6..2aecdc65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt @@ -36,7 +36,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupportReason import com.github.damontecres.wholphin.util.TrackType -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -90,7 +90,7 @@ fun PlaybackDebugOverlay( ) val memoryUsed by produceState("") { - withContext(Dispatchers.Default) { + withContext(WholphinDispatchers.Default) { while (isActive) { val runtime = Runtime.getRuntime() val total = runtime.totalMemory() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 96adc4df..d00fefea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -25,8 +25,8 @@ 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.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -41,19 +41,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.media3.common.Player -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.Playlist -import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -65,7 +63,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.TrickplayInfo import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * The overlay during playback showing controls, seek preview image, debug info, etc @@ -91,10 +88,10 @@ fun PlaybackOverlay( currentPlayback: CurrentPlayback?, currentSegment: MediaSegmentDto?, analyticsState: AnalyticsState, + queue: List<PlaylistItem>, modifier: Modifier = Modifier, trickplayInfo: TrickplayInfo? = null, trickplayUrlFor: (Int) -> String? = { null }, - playlist: Playlist = Playlist(listOf(), 0), onClickPlaylist: (BaseItem) -> Unit = {}, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { @@ -106,11 +103,11 @@ fun PlaybackOverlay( val density = LocalDensity.current val titleHeight = - remember(item?.title) { + remember(item?.title, density) { if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp } val subtitleHeight = - remember(item?.subtitleLong) { + remember(item?.subtitleLong, density) { if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp } @@ -180,10 +177,10 @@ fun PlaybackOverlay( } } val nextState = - remember(chapters, playlist) { + remember(chapters, nextEnabled) { if (chapters.isNotEmpty()) { OverlayViewState.CHAPTERS - } else if (playlist.hasNext()) { + } else if (nextEnabled) { OverlayViewState.QUEUE } else { null @@ -225,8 +222,7 @@ fun PlaybackOverlay( player = player, controllerViewState = controllerViewState, chapters = chapters, - playlist = playlist, - aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE, + hasNext = nextEnabled, onChangeState = onChangeState, modifier = Modifier @@ -237,9 +233,9 @@ fun PlaybackOverlay( } OverlayViewState.QUEUE -> { - if (playlist.hasNext()) { + if (nextEnabled) { QueueRowOverlay( - playlist = playlist, + queue = queue, controllerViewState = controllerViewState, nextState = remember(chapters) { @@ -261,60 +257,41 @@ fun PlaybackOverlay( } } - // Trickplay - AnimatedVisibility( - visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, - enter = - expandVertically( - spring( - stiffness = Spring.StiffnessMedium, - visibilityThreshold = IntSize.VisibilityThreshold, - ), - ) + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Box( - modifier = - Modifier - .align(Alignment.Center) - .fillMaxWidth(.95f), + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + // Trickplay + AnimatedVisibility( + visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused, + enter = + expandVertically( + spring( + stiffness = Spring.StiffnessMedium, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ) + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box( modifier = Modifier - .align(Alignment.BottomStart) - .offsetByPercent( - xPercentage = seekProgressPercent.coerceIn(0f, 1f), - ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + .align(Alignment.Center) + .fillMaxWidth(.95f), ) { - if (trickplayInfo != null) { - val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight - val index = - (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage - val imageUrl = remember(index) { trickplayUrlFor(index) } - - if (imageUrl != null) { - SeekPreviewImage( - modifier = Modifier, - previewImageUrl = imageUrl, - seekProgressMs = seekProgressMs, - trickPlayInfo = trickplayInfo, - ) - } - } - Text( - text = (seekProgressMs / 1000L).seconds.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .background( - Color.Black.copy(alpha = 0.6f), - shape = RoundedCornerShape(4.dp), - ).padding(horizontal = 8.dp, vertical = 4.dp), - ) + .align(Alignment.BottomStart) + .offsetByPercent( + xPercentage = seekProgressPercent.coerceIn(0f, 1f), + ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + ) { + TrickplayPreview( + seekProgressMs = seekProgressMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt index a44d1fc7..92b7afd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt @@ -22,7 +22,7 @@ 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.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.HiddenFocusBox import com.github.damontecres.wholphin.ui.ifElse @@ -31,14 +31,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun QueueRowOverlay( - playlist: Playlist, + queue: List<PlaylistItem>, controllerViewState: ControllerViewState, nextState: OverlayViewState, onChangeState: (OverlayViewState) -> Unit, onClickPlaylist: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { - val items = remember { playlist.upcomingItems() } val focusRequester = remember { FocusRequester() } val hiddenFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -70,7 +69,7 @@ fun QueueRowOverlay( } }, ) { - itemsIndexed(items) { index, item -> + itemsIndexed(queue) { index, item -> val interactionSource = remember { MutableInteractionSource() } val isFocused = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index d0dfec31..d2aae720 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -43,8 +43,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.playback.ControllerViewState @@ -108,6 +106,30 @@ fun SteppedSeekBarImpl( ) } +/** + * A read-only seek bar for showing progress without taking focus or handling D-Pad input. + * + * This is used by overlays where seeking is handled elsewhere and the bar is only a visual indicator. + */ +@Composable +fun StaticSeekBarImpl( + progress: Float, + durationMs: Long, + bufferedProgress: Float, + modifier: Modifier = Modifier, +) { + SeekBarDisplay( + enabled = false, + progress = progress, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + onLeft = {}, + onRight = {}, + interactionSource = remember { MutableInteractionSource() }, + modifier = modifier, + ) +} + /** * A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move */ @@ -178,7 +200,6 @@ private fun SeekBarDisplay( modifier: Modifier = Modifier, enabled: Boolean = true, ) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val color = MaterialTheme.colorScheme.border val onSurface = MaterialTheme.colorScheme.onSurface @@ -201,13 +222,7 @@ private fun SeekBarDisplay( Timber.v("Ignoring %s", event) return@onPreviewKeyEvent false } - val seekBack = - if (isLtr) { - isDpadLeft(event) - } else { - isDpadRight(event) - } - if (seekBack) { + if (isDpadLeft(event)) { when (event.type) { KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount @@ -276,68 +291,34 @@ private fun SeekBarDisplay( strokeWidth = size.height, cap = StrokeCap.Round, ) - if (isLtr) { - drawLine( - color = onSurface.copy(alpha = .65f), - start = Offset(x = 0f, y = yOffset), - end = - Offset( - x = size.width.times(bufferedProgress), - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(x = 0f, y = yOffset), - end = - Offset( - x = size.width.times(progress), - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - } else { - drawLine( - color = onSurface.copy(alpha = .65f), - start = - Offset( - x = size.width - size.width.times(bufferedProgress), - y = yOffset, - ), - end = - Offset( - x = size.width, - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(x = size.width - size.width.times(progress), y = yOffset), - end = - Offset( - x = size.width, - y = yOffset, - ), - strokeWidth = size.height, - cap = StrokeCap.Round, - ) - } + drawLine( + color = onSurface.copy(alpha = .65f), + start = Offset(x = 0f, y = yOffset), + end = + Offset( + x = size.width.times(bufferedProgress), + y = yOffset, + ), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(x = 0f, y = yOffset), + end = + Offset( + x = size.width.times(progress), + y = yOffset, + ), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) drawCircle( color = Color.White, radius = size.height + 2, center = Offset( - x = - if (isLtr) { - size.width.times(progress) - } else { - size.width - size.width.times(progress) - }, + x = size.width.times(progress), y = yOffset, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt new file mode 100644 index 00000000..4871a77a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt @@ -0,0 +1,63 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.background +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.formatDuration +import org.jellyfin.sdk.model.api.TrickplayInfo +import kotlin.time.Duration.Companion.seconds + +@Composable +fun TrickplayPreview( + seekProgressMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + if (trickplayInfo != null) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val index = + (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage + val imageUrl = remember(index) { trickplayUrlFor(index) } + + if (imageUrl != null) { + SeekPreviewImage( + modifier = Modifier, + previewImageUrl = imageUrl, + seekProgressMs = seekProgressMs, + trickPlayInfo = trickplayInfo, + ) + } + } + val resources = LocalResources.current + val seekProgressSec = seekProgressMs / 1000L + val seekText = remember(seekProgressSec) { resources.formatDuration(seekProgressSec.seconds) } + Text( + text = seekText, + 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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt index a3e50a85..b352c550 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.tv.material3.Text import com.github.damontecres.wholphin.R 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 index e8c768a4..7deeb95c 100644 --- 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 @@ -31,7 +31,6 @@ 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 @@ -44,9 +43,7 @@ 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 @@ -60,16 +57,14 @@ import com.github.damontecres.wholphin.ui.main.settings.MoveDirection 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 com.github.damontecres.wholphin.util.WholphinDispatchers 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.launch import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import javax.inject.Inject data class NavDrawerPin( @@ -232,7 +227,7 @@ fun NavDrawerPreferenceListItem( modifier = Modifier .fillMaxWidth() - .heightIn(min = 40.dp, max = 88.dp), + .heightIn(min = 40.dp), ) { ListItem( selected = false, @@ -304,11 +299,8 @@ 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, @@ -321,11 +313,11 @@ class NavDrawerPreferencesViewModel val state = navDrawerService.state.value val user = serverRepository.currentUser val seerr = seerrServerRepository.active.firstOrNull() - if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { + if (user == null || seerr == null) { return@launchDefault } val navDrawerPins = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { serverPreferencesDao .getNavDrawerPinnedItems(user) .associateBy { it.itemId } @@ -372,7 +364,8 @@ class NavDrawerPreferencesViewModel ) } serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - navDrawerService.updateNavDrawer(user, userDto) + val discoverActive = seerrServerRepository.active.firstOrNull() == true + navDrawerService.updateNavDrawer(user, userDto, discoverActive) } 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 06399fe9..0a843dcb 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 @@ -37,6 +37,7 @@ enum class PreferenceScreenOption { MPV, SCREENSAVER, SKIP_SEGMENTS, + EXPERIMENTAL, ; 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 982e2537..c97117b7 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 @@ -48,13 +48,17 @@ import coil3.imageLoader 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.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences +import com.github.damontecres.wholphin.preferences.ExperimentalPreference 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.SkipSegmentPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences +import com.github.damontecres.wholphin.preferences.experimentalPreferences +import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.Release @@ -96,11 +100,13 @@ fun PreferencesContent( val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } val currentUser by viewModel.currentUser.collectAsState() - val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } val players by viewModel.externalPlayers.collectAsState() @@ -133,7 +139,7 @@ fun PreferencesContent( } } - val movementSounds = true + val movementSounds = false val installedVersion = updateVM.currentVersion val updateAvailable = remember(updateState.release) { @@ -148,6 +154,7 @@ fun PreferencesContent( PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences + PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences } val screenTitle = when (preferenceScreenOption) { @@ -157,6 +164,7 @@ fun PreferencesContent( PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior + PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings } var visible by remember { mutableStateOf(false) } @@ -181,6 +189,12 @@ fun PreferencesContent( } } + val showUpdate = + UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + AnimatedVisibility( visible = visible, enter = fadeIn() + slideInHorizontally { it / 2 }, @@ -210,11 +224,7 @@ fun PreferencesContent( contentPadding = PaddingValues(16.dp), modifier = Modifier.fillMaxSize(), ) { - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable - ) { + if (showUpdate) { item { val updateFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -233,6 +243,7 @@ fun PreferencesContent( modifier = Modifier .focusRequester(updateFocusRequester) + .focusRequester(firstFocusRequester) .playSoundOnFocus(movementSounds), ) } @@ -258,15 +269,25 @@ fun PreferencesContent( .flatten() groupPreferences.forEachIndexed { prefIndex, pref -> pref as AppPreference<AppPreferences, Any> + val isFirst = groupIndex == 0 && prefIndex == 0 && !showUpdate + val isLast = + groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex item { val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value val focusModifier = Modifier .ifElse( groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, Modifier.focusRequester(focusRequester), + ).lazyListWrapScrolling( + state, + focused, + isFirst, + isLast, + firstFocusRequester, + lastFocusRequester, ) - val focused = interactionSource.collectIsFocusedAsState().value LaunchedEffect(focused) { if (focused) { focusedIndex = Pair(groupIndex, prefIndex) @@ -569,6 +590,46 @@ fun PreferencesContent( ) } + ExperimentalPreference.Enable -> { + var showConfirm by remember { mutableStateOf(false) } + pref as AppSwitchPreference<AppPreferences> + val value = pref.getter.invoke(preferences) + SwitchPreference( + title = stringResource(pref.title), + value = value, + onClick = { + if (value) { + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, false) + } + } + } else { + showConfirm = true + } + }, + summaryOn = pref.summaryOn?.let { stringResource(pref.summaryOn) }, + summaryOff = pref.summaryOff?.let { stringResource(pref.summaryOff) }, + ) + if (showConfirm) { + ConfirmDialog( + title = stringResource(R.string.confirm_enable_experimental_title), + body = stringResource(R.string.confirm_enable_experimental_body), + onCancel = { showConfirm = false }, + onConfirm = { + showConfirm = false + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, true) + } + } + }, + ) + } + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -576,7 +637,7 @@ fun PreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? @@ -762,6 +823,7 @@ fun PreferencesPage( PreferenceScreenOption.MPV, PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, + PreferenceScreenOption.EXPERIMENTAL, -> { PreferencesContent( initialPreferences, 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 718febf3..200bac4e 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 @@ -22,13 +22,12 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState 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.MutableStateFlow @@ -37,8 +36,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber import javax.inject.Inject @@ -52,21 +49,23 @@ class PreferencesViewModel val navigationManager: NavigationManager, val backdropService: BackdropService, val screensaverService: ScreensaverService, - private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, - private val deviceInfo: DeviceInfo, - private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - val currentUser - get() = - serverRepository.currentUserFlow.stateIn( - viewModelScope, - SharingStarted.Eagerly, - null, - ) + private val serverReportService: ServerReportService, + ) : ViewModel() { + val currentUser = + serverRepository.currentUserFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) val seerrConnection = seerrServerRepository.connection @@ -95,7 +94,7 @@ class PreferencesViewModel } fun sendAppLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } fun resetSubtitleSettings() { 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 ce686708..459ec479 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 @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize 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.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -18,7 +18,6 @@ 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.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle @@ -36,7 +35,6 @@ fun SliderPreference( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, summaryBelow: Boolean = false, - heightAdjustment: Dp = 0.dp, additionalSummary: @Composable (ColumnScope.() -> Unit)? = null, ) { val focused = interactionSource.collectIsFocusedAsState().value @@ -49,16 +47,15 @@ fun SliderPreference( val contentColor = contentColorFor(background) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.SpaceEvenly, modifier = modifier -// .height(80.dp) // not dense - .height(72.dp + heightAdjustment) // dense + .defaultMinSize(minHeight = 72.dp) .fillMaxWidth() .background(background, shape = RoundedCornerShape(8.dp)) - .padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense, + .padding(PaddingValues(horizontal = 16.dp, vertical = 12.dp)), ) { - PreferenceTitle(title, color = contentColor) + PreferenceTitle(title, color = contentColor, modifier = Modifier.padding(bottom = 8.dp)) ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) { additionalSummary?.invoke(this) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt index 372af78a..daf6fa07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt @@ -79,6 +79,8 @@ fun SwitchColors(): SwitchColors { AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, 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 index 8abc6dbe..32da9613 100644 --- 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 @@ -10,11 +10,14 @@ 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.Column 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.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +40,7 @@ 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.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.preferences.ClickPreference @@ -61,6 +65,8 @@ fun SubtitlePreferencesContent( val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val lastFocusRequester = remember { FocusRequester() } var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() @@ -79,112 +85,133 @@ fun SubtitlePreferencesContent( 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 = 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> + Text( + text = title, + 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), + ) { + prefList.forEachIndexed { groupIndex, group -> item { - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - onFocus.invoke(groupIndex, prefIndex) + 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 isFirst = groupIndex == 0 && prefIndex == 0 + val isLast = + groupIndex == prefList.lastIndex && prefIndex == groupPreferences.lastIndex + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + 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, - ) + if (preferences.useSeparateHdr && pref == SubtitleSettings.SeparateHdr) { + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + } } + val focusModifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).bringIntoViewRequester(bringIntoViewRequester) + .lazyListWrapScrolling( + state, + focused, + isFirst, + isLast, + firstFocusRequester, + lastFocusRequester, + ) - 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) { + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + scope.launch(ExceptionHandler()) { + val newValue = + SubtitlePreferences + .newBuilder() + .apply { resetSubtitles() } + .build() + onPreferenceChange.invoke(newValue) } + }, + interactionSource = interactionSource, + modifier = focusModifier, + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - onPreferenceChange.invoke( - pref.setter( - preferences, - newValue, - ), - ) + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(preferences, 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), - ), - ) + }, + interactionSource = interactionSource, + modifier = focusModifier, + ) + } } } } 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 b7f3d764..a737fe2a 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 @@ -4,6 +4,7 @@ import android.content.res.Configuration import android.graphics.Typeface import android.os.Build import android.view.Display +import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -12,7 +13,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.mpv.MPVLib import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference import com.github.damontecres.wholphin.preferences.AppDestinationPreference @@ -20,11 +23,13 @@ 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.InterfacePreferences import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup -import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor import timber.log.Timber @@ -245,6 +250,16 @@ object SubtitleSettings { setter = { prefs, _ -> prefs }, ) + val SeparateHdr = + AppSwitchPreference<SubtitlePreferences>( + title = R.string.different_for_hdr, + defaultValue = false, + getter = { it.useSeparateHdr }, + setter = { prefs, value -> + prefs.update { useSeparateHdr = value } + }, + ) + val HdrSettings = AppDestinationPreference<SubtitlePreferences>( title = R.string.hdr_subtitle_style, @@ -299,7 +314,14 @@ object SubtitleSettings { title = R.string.hdr, preferences = listOf( - HdrSettings, + SeparateHdr, + ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.useSeparateHdr }, + preferences = listOf(HdrSettings), + ), ), ), ) @@ -333,6 +355,13 @@ object SubtitleSettings { ) } + @OptIn(UnstableApi::class) + fun SubtitlePreferences.applyTo(view: SubtitleView) { + view.setStyle(toSubtitleStyle()) + view.setFixedTextSize(Dimension.SP, fontSize.toFloat()) + view.setBottomPaddingFraction(margin.toFloat() / 100f) + } + fun SubtitlePreferences.calculateEdgeSize(density: Density): Float = with(density) { (edgeThickness / 2f).dp.toPx() } fun SubtitlePreferences.applyToMpv( @@ -400,3 +429,21 @@ object SubtitleSettings { } inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build() + +fun InterfacePreferences.shouldEnableSeparateHdrToggle(): Boolean { + // If already separate, leave it + if (subtitlesPreferences.useSeparateHdr) return true + val defaultStyle = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() + // If SDR and HDR are the same, don't separate + return if (subtitlesPreferences == hdrSubtitlesPreferences) { + false + } else { + // If different, but HDR has not been changed from original settings, don't separate + hdrSubtitlesPreferences != defaultStyle + } +} 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 52b3570a..a96ab8fe 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 @@ -51,7 +51,7 @@ import timber.log.Timber @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, - hdrSettings: Boolean, + isPageForHdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { @@ -67,7 +67,7 @@ fun SubtitleStylePage( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { DisposableEffect(context) { - if (hdrSettings) { + if (isPageForHdrSettings) { Timber.v("Switching color mode to HDR") context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR } @@ -77,7 +77,7 @@ fun SubtitleStylePage( } } val prefs = - if (hdrSettings) { + if (isPageForHdrSettings) { preferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.interfacePreferences.subtitlesPreferences @@ -192,8 +192,8 @@ fun SubtitleStylePage( } val display = LocalView.current.display val prefList = - remember(hdrSettings, display) { - if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) { + remember(isPageForHdrSettings, display) { + if (!isPageForHdrSettings && SubtitleSettings.shouldShowHdr(display)) { // If not on HDR page and display is HDR capable, then show the HDR button SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup } else { @@ -202,13 +202,13 @@ fun SubtitleStylePage( } SubtitlePreferencesContent( title = - if (hdrSettings) { + if (isPageForHdrSettings) { stringResource(R.string.hdr_subtitle_style) } else { stringResource(R.string.subtitle_style) }, preferences = - if (hdrSettings) { + if (isPageForHdrSettings) { preferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.interfacePreferences.subtitlesPreferences @@ -217,7 +217,7 @@ fun SubtitleStylePage( onPreferenceChange = { newSubtitlePrefs -> viewModel.preferenceDataStore.updateData { it.updateInterfacePreferences { - if (hdrSettings) { + if (isPageForHdrSettings) { hdrSubtitlesPreferences = newSubtitlePrefs } else { subtitlesPreferences = newSubtitlePrefs diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt new file mode 100644 index 00000000..c68239b0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt @@ -0,0 +1,145 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +/** + * A filterable list of language choices ([PreferredLanguageType]) + */ +@Composable +fun FilterableLanguagePreference( + @StringRes title: Int, + selectedOption: PreferredLanguageType, + options: List<PreferredLanguageType>, + onClickOption: (PreferredLanguageType) -> Unit, + modifier: Modifier = Modifier, +) { + var query by remember { mutableStateOf("") } + + var filteredOptions by remember { mutableStateOf(options) } + LaunchedEffect(query) { + delay(500.milliseconds) + if (query.isNotNullOrBlank()) { + withContext(WholphinDispatchers.Default) { + val q = query.lowercase() + filteredOptions = + options + .filter { + // Only show languages, not "built-in" types + it is PreferredLanguageType.Language && + (it.name.lowercase().contains(q) || it.iso.contains(q)) + }.sortedByDescending { + // User is likely typing the first letters of a language, so show those first + // IE, for q="en", show English before Armenian and Bengali + (it as PreferredLanguageType.Language).name.lowercase().startsWith(q) + } + } + } else { + filteredOptions = options + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val listState = rememberLazyListState() + val focusRequesters = remember { List(filteredOptions.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } + LazyColumn( + state = listState, + modifier = Modifier, + ) { + item { + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { focusRequesters.firstOrNull()?.tryRequestFocus() }, + modifier = + Modifier + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) + } + itemsIndexed(filteredOptions) { index, option -> + if (option is PreferredLanguageType.Divider) { + HorizontalDivider() + } else { + ListItem( + selected = false, + onClick = { + onClickOption.invoke(option) + }, + leadingContent = { + SelectedLeadingContent(option == selectedOption) + }, + headlineContent = { + val text = + when (option) { + PreferredLanguageType.AnyLanguage -> option.displayString.getString() + is PreferredLanguageType.Language -> option.displayString.getString() + is PreferredLanguageType.ServerProfile -> stringResource(R.string.use_user_profile) + } + Text(text) + }, + supportingContent = + when (option) { + PreferredLanguageType.AnyLanguage, + is PreferredLanguageType.Language, + -> { + null + } + + is PreferredLanguageType.ServerProfile -> { + option.name?.let { + { + Text(it) + } + } + } + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt new file mode 100644 index 00000000..dc8da479 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt @@ -0,0 +1,55 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.ConcatStringProvider +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +/** + * UI information for displaying a preferred language + */ +sealed interface PreferredLanguageType { + val displayString: StringProvider + + data class ServerProfile( + val name: String?, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = + ConcatStringProvider( + " - ", + buildList { + add(ResStringProvider(R.string.use_user_profile)) + if (name.isNotNullOrBlank()) { + add(StringStringProvider(name)) + } + }, + ) + } + + data object AnyLanguage : PreferredLanguageType { + override val displayString: StringProvider + get() = ResStringProvider(R.string.any_language) + } + + data class Language( + val iso: String, + val name: String, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = StringStringProvider(name) + } + + data object Divider : PreferredLanguageType { + override val displayString: StringProvider + get() = EmptyStringProvider + } +} + +data class PreferredLanguage( + val selected: PreferredLanguageType = PreferredLanguageType.ServerProfile(null), + val options: List<PreferredLanguageType> = emptyList(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt new file mode 100644 index 00000000..8b4dd64f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt @@ -0,0 +1,151 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings +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.ui.combineTriple +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.model.api.CultureDto +import javax.inject.Inject + +@HiltViewModel +class UserPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore<AppPreferences>, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + val screensaverService: ScreensaverService, + private val serverRepository: ServerRepository, + private val serverDao: JellyfinServerDao, + ) : ViewModel() { + private val mutex = Mutex() + + val currentUser = serverRepository.currentUserFlow + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + private val serverLanguages = MutableStateFlow<List<CultureDto>>(emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val userAppPreferences: StateFlow<JellyfinUserPreferences> = + currentUser.mapNotNull { it?.appPreferences }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + JellyfinUserPreferences(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createLanguageFlow(isAudio: Boolean): StateFlow<PreferredLanguage> = + currentUser + .combineTriple(serverRepository.currentUserDtoFlow, serverLanguages) + .mapLatest { (user, userDto, serverLanguages) -> + if (user == null || userDto == null || serverLanguages.isEmpty() || user.id != userDto.id) { + return@mapLatest PreferredLanguage() + } + val languages = + serverLanguages.filter { it.threeLetterIsoLanguageName.isNotNullOrBlank() } + + // Language user has chosen from local app + val prefLang = user.appPreferences.let { if (isAudio) it.preferredAudioLanguage else it.preferredSubtitleLanguage } + + // Language user has chosen from server user profile + val userDisplayLang = + userDto.configuration + ?.let { if (isAudio) it.audioLanguagePreference else it.subtitleLanguagePreference } + .let { userLang -> + languages.firstOrNull { it.threeLetterIsoLanguageName == userLang }?.displayName + ?: userLang + } + val selected = + when (prefLang) { + UserProfileSettings.USE_USER_PROFILE -> { + PreferredLanguageType.ServerProfile(userDisplayLang) + } + + UserProfileSettings.PREFER_ANY_LANGUAGE -> { + PreferredLanguageType.AnyLanguage + } + + else -> { + languages + .firstOrNull { it.threeLetterIsoLanguageName == prefLang } + ?.let { + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ) + } + } + } ?: PreferredLanguageType.ServerProfile(userDisplayLang) + val options = + buildList { + add(PreferredLanguageType.ServerProfile(userDisplayLang)) + add(PreferredLanguageType.AnyLanguage) + add(PreferredLanguageType.Divider) + languages.forEach { + if (it.threeLetterIsoLanguageName.isNotNullOrBlank()) { + add( + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ), + ) + } + } + } + PreferredLanguage(selected, options) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + PreferredLanguage(), + ) + + val audioLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(true) + val subtitleLanguage: StateFlow<PreferredLanguage> = createLanguageFlow(false) + + init { + viewModelScope.launchIO { + serverLanguages.value = api.localizationApi.getCultures().content + } + } + + fun updatePreferences(block: (JellyfinUserPreferences) -> JellyfinUserPreferences) { + viewModelScope.launchIO { + mutex.withLock { + val user = currentUser.first { it != null }!! + val newAppPrefs = block.invoke(user.appPreferences) + serverDao.updateUser(user.copy(appPreferences = newAppPrefs)) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt new file mode 100644 index 00000000..1308e08e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt @@ -0,0 +1,321 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.widget.Toast +import androidx.annotation.StringRes +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.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 +import androidx.compose.foundation.lazy.rememberLazyListState +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.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +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.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ChoicePreference +import com.github.damontecres.wholphin.ui.preferences.ClickPreference +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import timber.log.Timber + +/** + * Page for showing settings derived from the user's profile on the server + * + * @see UserProfileSettings + */ +@Composable +fun UserProfilePreferencesPage(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.background), + ) { + UserProfilePreferencesContent( + Modifier + .fillMaxWidth(.4f) + .fillMaxHeight() + .align(Alignment.TopEnd), + ) + } +} + +@Composable +fun UserProfilePreferencesContent( + modifier: Modifier = Modifier, + viewModel: UserPreferencesViewModel = hiltViewModel(), + onFocus: (Int, Int) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + + val userDto by viewModel.currentUserDto.collectAsState() + val preferences by viewModel.userAppPreferences.collectAsState() + val audioLanguagePref by viewModel.audioLanguage.collectAsState() + val subtitleLanguagePref by viewModel.subtitleLanguage.collectAsState() + var showPreferredLanguageDialog by remember { mutableStateOf<Boolean?>(null) } + + 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() + } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + ) { + Text( + text = stringResource(R.string.profile_specific_settings), + 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, + ) { + UserProfileSettings.Preferences.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<JellyfinUserPreferences, Any> + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + + val modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) focusedIndex = Pair(groupIndex, prefIndex) + } + + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } + } + when (pref) { + UserProfileSettings.PreferredAudioLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = true }, + summary = audioLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.PreferredSubtitleLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = false }, + summary = subtitleLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.SubtitleModePref -> { + pref as AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference> + val value = preferences.subtitleMode + val values = stringArrayResource(pref.displayValues).toList() + val summary = + when (value) { + SubtitleModePreference.USE_USER_PROFILE -> { + val userMode = userDto?.configuration?.subtitleMode + if (userMode != null) { + "${values[0]} - ${stringResource(userMode.stringRes)}" + } else { + values[0] + } + } + + else -> { + values[value.ordinal] + } + } + val selectedIndex = + remember(value) { pref.valueToIndex.invoke(value) } + ChoicePreference( + title = stringResource(pref.title), + summary = summary, + possibleValues = values, + selectedIndex = selectedIndex, + onValueChange = { index -> + viewModel.updatePreferences { + it.copy(subtitleMode = SubtitleModePreference.entries[index]) + } + }, + modifier = modifier, + interactionSource = interactionSource, + subtitleDisplay = { index, _ -> + val userMode = userDto?.configuration?.subtitleMode + if (index == 0 && userMode != null) { + { + Text(stringResource(userMode.stringRes)) + } + } else { + null + } + }, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(preferences, newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + viewModel.updatePreferences { + pref.setter(preferences, newValue) + } + } + } + }, + interactionSource = interactionSource, + modifier = modifier, + ) + } + } + } + } + } + } + } + } + showPreferredLanguageDialog?.let { isAudio -> + BasicDialog( + onDismissRequest = { showPreferredLanguageDialog = null }, + elevation = 3.dp, + ) { + val lang = if (isAudio) audioLanguagePref else subtitleLanguagePref + FilterableLanguagePreference( + title = if (isAudio) R.string.preferred_audio_language else R.string.preferred_subtitle_language, + selectedOption = lang.selected, + options = lang.options, + onClickOption = { option -> + val value = + when (option) { + PreferredLanguageType.AnyLanguage -> UserProfileSettings.PREFER_ANY_LANGUAGE + is PreferredLanguageType.Language -> option.iso + is PreferredLanguageType.ServerProfile -> UserProfileSettings.USE_USER_PROFILE + PreferredLanguageType.Divider -> throw IllegalStateException("Cannot click on a divider") + } + Timber.v("Updating language pref to %s", option) + viewModel.updatePreferences { + if (isAudio) { + it.copy(preferredAudioLanguage = value) + } else { + it.copy(preferredSubtitleLanguage = value) + } + } + showPreferredLanguageDialog = null + }, + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) + } + } +} + +val SubtitlePlaybackMode.stringRes: Int + @StringRes get() = + when (this) { + SubtitlePlaybackMode.DEFAULT -> R.string.subtitle_mode_default + SubtitlePlaybackMode.ALWAYS -> R.string.subtitle_mode_always + SubtitlePlaybackMode.ONLY_FORCED -> R.string.subtitle_mode_only_forced + SubtitlePlaybackMode.NONE -> R.string.subtitle_mode_none + SubtitlePlaybackMode.SMART -> R.string.subtitle_mode_smart + } 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/search/SearchForDialog.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt index de47d732..17beee6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup @@ -51,7 +51,6 @@ 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 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/search/SearchForViewModel.kt similarity index 97% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt index 3ab03300..e34697ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,7 +7,6 @@ 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.ui.toBaseItems import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt new file mode 100644 index 00000000..f20222d9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt @@ -0,0 +1,774 @@ +package com.github.damontecres.wholphin.ui.search + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +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.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.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.data.model.DiscoverItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.ItemRowTitle +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.detail.GridItemDetails +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.titleStringRes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +internal const val SEARCH_ROW = 0 +internal const val TAB_ROW = SEARCH_ROW + 1 +internal const val SEERR_ROW = TAB_ROW + 1 +internal const val COMBINED_ROW = SEERR_ROW +internal const val RESULTS_START = SEERR_ROW + 1 + +@Composable +fun SearchPage( + initialQuery: String, + userPreferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + val programDialogState by viewModel.programDialogState.collectAsState() + + // Start with current preferences, but collect updates when view options change + val prefs = + viewModel.userPreferencesService.flow + .collectAsState(userPreferences) + .value.appPreferences.interfacePreferences.searchPreferences + val combinedMode = prefs.combinedSearchResults + val voiceSearchButtonVisible = prefs.showVoiceSearchButton + +// val query = rememberTextFieldState() + var query by rememberSaveable { mutableStateOf(initialQuery) } + val focusRequesters = + remember(state.includedSearchableTypes.size) { List(RESULTS_START + state.includedSearchableTypes.size) { FocusRequester() } } + + val seerrActive by viewModel.seerrActive.collectAsState() + var selectedTab by rememberSaveable(seerrActive, state.discoverEnabled) { mutableIntStateOf(0) } + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var showFilterTypeDialog by rememberSaveable { mutableStateOf(false) } + var searchClicked by rememberSaveable(query) { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + var showProgramDialog by remember { mutableStateOf(false) } + + val position by viewModel.position.collectAsState() + + fun setPosition(pos: RowColumn) { + Timber.v("pos=%s", pos) + viewModel.position.value = pos + } + + val contextMenu = rememberContextMenu(userPreferences, viewModel) + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery, combinedMode) + } + + LaunchedEffect(query, combinedMode) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750.milliseconds) + viewModel.search(query, combinedMode) + } + } + } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + val onClickItem = { _: Int, item: BaseItem -> + Timber.v("Clicked %s, type=%s", item.id, item.type) + if (item.type == BaseItemKind.TV_PROGRAM || item.type == BaseItemKind.PROGRAM || item.type == BaseItemKind.LIVE_TV_PROGRAM) { + viewModel.fetchProgramForDialog(item.id) + showProgramDialog = true + } else { + viewModel.navigationManager.navigateTo(item.destination()) + } + } + val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> + setPosition(RowColumn(rowIndex, index)) + contextMenu.showContextMenu(index, item) + } + val onPlayItem = { _: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } + + val onClickDiscover = { _: Int, item: DiscoverItem -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + val positionCallback = { columns: Int, index: Int -> + showHeader = index < columns + } + val showTabs = + seerrActive && state.discoverEnabled && query.isNotBlank() && showHeader && combinedMode + val isLibraryTab = selectedTab == 0 + + LaunchedEffect(seerrActive, query) { + if (!seerrActive || query.isBlank()) { + selectedTab = 0 + } + } + + LaunchedEffect( + searchClicked, + state, + combinedMode, + selectedTab, + seerrActive, + ) { + if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect + + withContext(WholphinDispatchers.IO) { + // Want to focus on the first successful row after all the ones before it are finished searching + val results = + if (isLibraryTab) { + if (combinedMode) { + listOf(state.combinedResults) + } else { + state.includedSearchableTypes.map { state.results[it] } + } + } else { + listOf(state.seerrResults) + } + val firstSuccess = + results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } + if (firstSuccess >= 0) { + val anyBeforeSearching = + results.subList(0, firstSuccess).any { it is SearchResult.Searching } + if (!anyBeforeSearching) { + val targetRow = + if (isLibraryTab) { + if (combinedMode) { + COMBINED_ROW + } else { + firstSuccess + } + } else { + SEERR_ROW + } +// setPosition(RowColumn(targetRow, 0)) + onMain { focusRequesters.getOrNull(targetRow)?.tryRequestFocus() } + } + } + } + } + + val tabs = + remember(seerrActive, state.discoverEnabled) { + buildList { + add(TabDetails(R.string.library)) + if (seerrActive && state.discoverEnabled) { + add(TabDetails(R.string.discover)) + } + } + } + + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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(0.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + AnimatedVisibility( + visible = voiceSearchButtonVisible, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + modifier = Modifier.padding(end = 12.dp), + ) + } + + 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 + } + }, + ) + + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = { showViewOptions = true }, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + AnimatedVisibility( + visible = showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTab, + tabs = tabs, + onClick = { + selectedTab = it + val row = + when (selectedTab) { + 0 -> COMBINED_ROW + else -> SEERR_ROW + } + setPosition(RowColumn(row, 0)) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp) + .onFocusChanged { + if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) + }, + ) + } + Box( + modifier = Modifier.fillMaxSize(), + ) { +// SideEffect { +// Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) +// } + when { + isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.combinedResults, + focusRequester = focusRequesters[COMBINED_ROW], + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(COMBINED_ROW, index, item) + }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + !isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.seerrResults, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + isLibraryTab -> { + LazyColumn( + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 44.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusGroup(), + ) { + itemsIndexed(state.includedSearchableTypes) { index, type -> + val rowIndex = RESULTS_START + index + val result = state.results.getOrDefault(type, SearchResult.Searching) + val focusRequester = + remember(state.includedSearchableTypes.size) { + focusRequesters.getOrNull(rowIndex) ?: FocusRequester() + } + SearchRowResult( + title = type.titleStringRes, + result = result, + rowIndex = rowIndex, + position = position, + focusRequester = focusRequester, + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(rowIndex, index, item) + }, + onClickPosition = { setPosition(it) }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SearchPageCard( + item = item, + type = type, + onClick = { + setPosition(RowColumn(rowIndex, index)) + onClick.invoke() + }, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + + if (seerrActive && state.discoverEnabled) { + item { + SearchRowResult( + title = R.string.discover, + result = state.seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onClickDiscover = onClickDiscover, + onClickPosition = { setPosition(it) }, + cardContent = { _, _, _, _, _ -> }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + contextMenu.Compose() + } + + if (showViewOptions) { + SearchViewOptionsDialog( + combinedResults = combinedMode, + onCombinedResultsChange = viewModel::setCombinedResults, + voiceSearchButtonVisible = voiceSearchButtonVisible, + onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, + onClickFilterTypes = { showFilterTypeDialog = true }, + onDismissRequest = { showViewOptions = false }, + ) + } + + if (showFilterTypeDialog) { + SearchTypeOptionsDialog( + onDismissRequest = { showFilterTypeDialog = false }, + searchableTypes = state.possibleSearchableTypes, + excludedSearchableTypes = state.excludedSearchableTypes, + discoverAvailable = seerrActive, + discoverEnabled = state.discoverEnabled, + onClick = viewModel::onClickExcludeSearchableType, + onClickDiscover = viewModel::onClickExcludeDiscover, + ) + } + + if (showProgramDialog) { + val context = LocalContext.current + val onDismissRequest = { showProgramDialog = false } + ProgramDialog( + state = programDialogState.loading, + canRecord = true, + onDismissRequest = onDismissRequest, + onWatch = { + onDismissRequest.invoke() + val channelId = it.data.channelId + if (channelId != null) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ), + ) + } else { + Toast.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG).show() + } + }, + onRecord = { program, series -> + viewModel.record( + programId = program.id, + series = series, + ) + onDismissRequest.invoke() + }, + onCancelRecord = { program, series -> + viewModel.cancelRecording( + series = series, + timerId = if (series) program.data.seriesTimerId else program.data.timerId, + ) + onDismissRequest.invoke() + }, + ) + } +} + +@Composable +fun SearchCombinedResults( + result: SearchResult, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onPlayItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + when (result) { + SearchResult.NoQuery -> {} + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.searching), + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = result.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Success -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onPlayItem = onPlayItem, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + GridCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + fillWidth = details.widthPx, + ) + }, + modifier = modifier, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickDiscover, + onLongClickItem = { _, _ -> }, + onPlayItem = { _, _ -> }, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + DiscoverItemCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + ) + }, + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun <T : CardGridItem> SearchGrid( + items: List<T?>, + focusRequester: FocusRequester, + onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, + onPlayItem: (Int, T) -> Unit, + onClickPosition: (RowColumn) -> Unit, + cardContent: @Composable (GridItemDetails<T>) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = modifier, + ) { + ItemRowTitle(stringResource(R.string.results)) + + CardGrid( + pager = items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onClickItem.invoke(index, item) + }, + onLongClickItem = onLongClickItem, + onClickPlay = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onPlayItem.invoke(index, item) + }, + letterPosition = { -1 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + cardContent = cardContent, + ) + } +} + +@Composable +fun SearchRowResult( + @StringRes title: Int, + result: SearchResult, + rowIndex: Int, + position: RowColumn, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, + cardContent: @Composable ( + index: Int, + item: BaseItem?, + modifier: Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) -> Unit, +) { + when (val r = result) { + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(title), + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + } + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(title), + message = stringResource(R.string.searching), + modifier = modifier, + ) + } + + is SearchResult.Success -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + modifier = modifier.focusRequester(focusRequester), + cardContent = cardContent, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier.focusRequester(focusRequester), + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } + } +} + +@Composable +fun SearchResultPlaceholder( + title: String, + message: String, + modifier: Modifier = Modifier, + messageColor: Color = MaterialTheme.colorScheme.onBackground, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = message, + style = MaterialTheme.typography.titleMedium, + color = messageColor, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt new file mode 100644 index 00000000..9b82f24c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.ui.search + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.PersonCard +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.cards.personRowCardWidth +import com.github.damontecres.wholphin.ui.cards.rememberImageUrl +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import java.time.OffsetDateTime + +@Composable +fun SearchPageCard( + item: BaseItem?, + type: BaseItemKind, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + when (type) { + BaseItemKind.EPISODE -> { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.padding(horizontal = 8.dp), + cardHeight = Cards.heightEpisode, + ) + } + + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = modifier, + ) + } + + BaseItemKind.PERSON -> { + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(item) { + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + ) + } + PersonCard( + name = item?.name ?: "", + role = null, + imageUrl = imageUrl, + favorite = item?.favorite ?: false, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.width(personRowCardWidth), + ) + } + + BaseItemKind.TV_CHANNEL -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + modifier = modifier, + ) + } + + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> { + val subtitle = + remember(item) { + val startDate = item?.data?.startDate + val endDate = item?.data?.endDate + if (startDate != null && endDate != null) { + DateUtils.formatDateRange( + context, + startDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + endDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY, + ) + } else { + null + } + } + + SeasonCard( + title = item?.title, + subtitle = subtitle, + name = item?.name, + imageUrl = rememberImageUrl(item, Cards.HEIGHT_LIVE_TV.dp, Dp.Unspecified), + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + imageWidth = Dp.Unspecified, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + ) + } + + else -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + showImageOverlay = true, + modifier = modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt new file mode 100644 index 00000000..f8a6eb2e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.main.settings.TitleText +import com.github.damontecres.wholphin.ui.preferences.SwitchColors +import com.github.damontecres.wholphin.ui.titleStringRes +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchTypeOptionsDialog( + onDismissRequest: () -> Unit, + searchableTypes: List<BaseItemKind>, + excludedSearchableTypes: List<BaseItemKind>, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) { + SearchTypeOptionsDialogContent( + searchableTypes = searchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + discoverAvailable = discoverAvailable, + discoverEnabled = discoverEnabled, + onClick = onClick, + onClickDiscover = onClickDiscover, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun SearchTypeOptionsDialogContent( + searchableTypes: List<BaseItemKind>, + excludedSearchableTypes: List<BaseItemKind>, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + TitleText(stringResource(R.string.include_types)) + + LazyColumn { + items(searchableTypes) { searchableType -> + val checked = searchableType !in excludedSearchableTypes + ListItem( + enabled = true, + selected = false, + onClick = { onClick.invoke(searchableType) }, + headlineContent = { + Text(stringResource(searchableType.titleStringRes)) + }, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + if (discoverAvailable) { + item { + ListItem( + enabled = true, + selected = false, + onClick = onClickDiscover, + headlineContent = { + Text(stringResource(R.string.discover)) + }, + trailingContent = { + Switch( + checked = discoverEnabled, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt new file mode 100644 index 00000000..044178b2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -0,0 +1,627 @@ +package com.github.damontecres.wholphin.ui.search + +import android.content.Context +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.datastore.core.DataStore +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.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateSearchPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.KeyValueService +import com.github.damontecres.wholphin.services.LiveTvService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerReportService +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.ProgramItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialogState +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.showToast +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.SearchRelevance +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + val api: ApiClient, + val navigationManager: NavigationManager, + val appPreferences: DataStore<AppPreferences>, + private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, + val userPreferencesService: UserPreferencesService, + private val serverRepository: ServerRepository, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + private val serverReportService: ServerReportService, + private val liveTvService: LiveTvService, + private val keyValueService: KeyValueService, + private val navDrawerService: NavDrawerService, + ) : ViewModel(), + ContextMenuProvider { + val seerrActive = + seerrService.active.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow<SearchState> = _state + val position = MutableStateFlow(RowColumn(0, 0)) + + private var currentQuery: String? = null + private var combinedMode = false + + private val _programDialogState = MutableStateFlow(ProgramDialogState()) + val programDialogState: StateFlow<ProgramDialogState> = _programDialogState + + private var userLibraryTypes: Set<BaseItemKind> = emptySet() + + init { + init() + } + + private fun init() { + viewModelScope.launchDefault { + userLibraryTypes = + navDrawerService.state.value.allLibraries + .flatMap { it.collectionType.baseItemKinds } + .toSet() + + val excludedSearchableTypes = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get<List<BaseItemKind>>( + userId, + EXCLUDED_SEARCHABLE_TYPES_KEY, + emptyList(), + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + emptyList() + } + } ?: emptyList() + val discoverEnabled = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get( + userId, + INCLUDE_DISCOVER_KEY, + true, + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + true + } + } ?: true + val searchableTypes = determineSearchableTypes(excludedSearchableTypes) + val possibleSearchableTypes = determineSearchableTypes(emptyList()) + _state.update { + it.copy( + discoverEnabled = discoverEnabled, + includedSearchableTypes = searchableTypes, + possibleSearchableTypes = possibleSearchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + searchableTypes.forEach { put(it, SearchResult.NoQuery) } + }, + ) + } + } + } + + private fun determineSearchableTypes(excludedSearchableTypes: List<BaseItemKind>): List<BaseItemKind> { + val tvAccess = serverRepository.currentUserDto?.tvAccess == true + val searchableTypes = + allSearchableTypes.filter { + val excluded = excludedSearchableTypes.contains(it) + // Only include if there's a relevant, accessible library + val hasLibrary = userLibraryTypes.contains(it) + when (it) { + // No library type for person + BaseItemKind.PERSON -> !excluded + + // Remove live tv search if user doesn't have access + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.PROGRAM, + -> tvAccess && !excluded + + else -> hasLibrary && !excluded + } + } + return searchableTypes + } + + fun search( + query: String?, + combined: Boolean = false, + force: Boolean = false, + ) { + if (currentQuery == query && combinedMode == combined && !force) { + return + } + currentQuery = query + combinedMode = combined + if (query.isNotNullOrBlank()) { + _state.update { + it.copy( + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.Searching) + } + }, + combinedResults = SearchResult.Searching, + ) + } + if (combined) { + searchCombined(query) + } else { + state.value.includedSearchableTypes.forEach { type -> + searchType(query, type) + } + } + searchSeerr(query) + } else { + _state.update { + it.copy( + results = + SnapshotStateMap<BaseItemKind, SearchResult>().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.NoQuery) + } + }, + combinedResults = SearchResult.NoQuery, + ) + } + } + } + + private fun searchType( + query: String, + type: BaseItemKind, + ) { + viewModelScope.launchIO { + try { + val items: List<BaseItem> = + when (type) { + BaseItemKind.LIVE_TV_PROGRAM -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = ProgramItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + + BaseItemKind.MUSIC_ARTIST -> { + api.artistsApi + .getArtists( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ).toBaseItems(api, false) + } + + BaseItemKind.PERSON -> { + api.personsApi + .getPersons( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ).toBaseItems(api, false) + } + + else -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + } + val sorted = + items.sortedWith( + compareBy<BaseItem> { SearchRelevance.score(it, query) }, + ) + Timber.v("Search finished for %s, %s results", type, sorted.size) + _state.value.results[type] = SearchResult.Success(sorted) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception searching for $type") + _state.value.results[type] = SearchResult.Error(ex) + } + } + } + + private fun searchCombined(query: String) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + try { + Timber.v("Starting searchCombined") + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = state.value.includedSearchableTypes, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ) + + val result = api.itemsApi.getItems(request).content + val items = + result.items.map { + BaseItem(it, false) + } + val sorted = + items.sortedWith( + compareBy<BaseItem> { SearchRelevance.score(it, query) }, + ) + Timber.v("searchCombined complete %s results", sorted.size) + _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } + } catch (ex: Exception) { + Timber.e(ex, "Exception in combined search") + _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } + } + } + } + + fun setCombinedResults(enabled: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + combinedSearchResults = enabled + } + } + } + } + + fun setVoiceSearchButtonVisible(visible: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + showVoiceSearchButton = visible + } + } + } + } + + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrActive.first() && state.value.discoverEnabled) { + _state.update { it.copy(seerrResults = SearchResult.Searching) } + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } + } + } + } + + init { + addCloseable(voiceInputManager) + } + + override fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshItem(item.id) + } + } + } + + private suspend fun refreshItem(itemId: UUID) { + try { + val position = position.value + val searchResult = + if (combinedMode) { + state.value.combinedResults + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { + state.value.results[it] + } + } ?: return + val items = (searchResult as? SearchResult.Success)?.items ?: return + + Timber.v("Item refresh: position=%s", position) + val item = items.getOrNull(position.column) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item != null && item.id == itemId) { + val newItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + val newList = + SearchResult.Success( + items.toMutableList().apply { + set(position.column, newItem) + }, + ) + if (combinedMode) { + _state.update { + it.copy( + combinedResults = newList, + ) + } + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { type -> + state.value.results[type] = newList + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing item %s", itemId) + showToast(context, "Error refreshing") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshItem(itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + refreshItem(itemId) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = serverReportService.sendMediaReportFor(itemId) + + fun fetchProgramForDialog(programId: UUID) { + _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } + viewModelScope.launchDefault { + try { + val result = liveTvService.fetchProgramForDialog(programId) + _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching program $programId") + _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + + fun cancelRecording( + series: Boolean, + timerId: String?, + ) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + // TODO update program card? + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun record( + programId: UUID, + series: Boolean, + ) { + viewModelScope.launchIO { + try { + liveTvService.record(programId, series) + // TODO update program card? + refreshItem(programId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeSearchableType(type: BaseItemKind) { + viewModelScope.launchIO { + try { + val state = state.value + val updateSearch: Boolean + val newExcludes = + state.excludedSearchableTypes.toMutableList().apply { + if (type in this) { + updateSearch = true + remove(type) + } else { + // If combined, need to update, otherwise just hide the newly excluded results + updateSearch = combinedMode + add(type) + } + } + Timber.v("newExcludes=%s", newExcludes) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, EXCLUDED_SEARCHABLE_TYPES_KEY, newExcludes) + } + val searchableTypes = determineSearchableTypes(newExcludes) + _state.update { + it.copy( + includedSearchableTypes = searchableTypes, + excludedSearchableTypes = newExcludes, + ) + } + if (updateSearch && currentQuery.isNotNullOrBlank()) { + Timber.d("Need to update search") + search(currentQuery, combinedMode, true) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception toggling %s", type) + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeDiscover() { + viewModelScope.launchIO { + try { + val newIncludeDiscover = state.value.discoverEnabled.not() + Timber.v("newIncludeDiscover=%s", newIncludeDiscover) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, INCLUDE_DISCOVER_KEY, newIncludeDiscover) + } + _state.update { + it.copy( + discoverEnabled = newIncludeDiscover, + ) + } + if (newIncludeDiscover) { + currentQuery?.takeIf { it.isNotNullOrBlank() }?.let { query -> + searchSeerr(query) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception discover") + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + companion object { + private const val EXCLUDED_SEARCHABLE_TYPES_KEY = "excludedSearchableTypes" + private const val INCLUDE_DISCOVER_KEY = "searchIncludeDiscover" + } + } + +sealed interface SearchResult { + data object NoQuery : SearchResult + + data object Searching : SearchResult + + data class Error( + val ex: Exception, + ) : SearchResult + + data class Success( + val items: List<BaseItem?>, + ) : SearchResult + + data class SuccessSeerr( + val items: List<DiscoverItem>, + ) : SearchResult +} + +data class SearchState( + val results: SnapshotStateMap<BaseItemKind, SearchResult> = SnapshotStateMap(), + val seerrResults: SearchResult = SearchResult.NoQuery, + val combinedResults: SearchResult = SearchResult.NoQuery, + val possibleSearchableTypes: List<BaseItemKind> = emptyList(), + val includedSearchableTypes: List<BaseItemKind> = emptyList(), + val excludedSearchableTypes: List<BaseItemKind> = emptyList(), + val discoverEnabled: Boolean = true, +) + +private val allSearchableTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +private const val SEARCH_LIMIT = 50 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt new file mode 100644 index 00000000..cdbd69e1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt @@ -0,0 +1,121 @@ +package com.github.damontecres.wholphin.ui.search + +import android.view.Gravity +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.preferences.SwitchColors + +@Composable +fun SearchViewOptionsDialog( + combinedResults: Boolean, + onCombinedResultsChange: (Boolean) -> Unit, + voiceSearchButtonVisible: Boolean, + onVoiceSearchButtonVisibleChange: (Boolean) -> Unit, + onClickFilterTypes: () -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.CENTER) + + Box( + modifier = + Modifier + .width(400.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + RoundedCornerShape(28.dp), + ).padding(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.combined_search_results)) + }, + supportingContent = { + Text( + if (combinedResults) { + stringResource(R.string.combined_search_results_on) + } else { + stringResource(R.string.combined_search_results_off) + }, + ) + }, + trailingContent = { + Switch( + checked = combinedResults, + onCheckedChange = onCombinedResultsChange, + colors = SwitchColors(), + ) + }, + onClick = { onCombinedResultsChange(!combinedResults) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.show_voice_search_button)) + }, + supportingContent = { + Text( + if (voiceSearchButtonVisible) { + stringResource(R.string.visible_ui) + } else { + stringResource(R.string.hidden_ui) + }, + ) + }, + trailingContent = { + Switch( + checked = voiceSearchButtonVisible, + onCheckedChange = onVoiceSearchButtonVisibleChange, + colors = SwitchColors(), + ) + }, + onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.include_types)) + }, + onClick = onClickFilterTypes, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt index f9f22591..ef97620c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt @@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.R 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.components.TextButton import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.theme.WholphinTheme 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 84e4cea6..81cc7c2a 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 @@ -97,6 +97,7 @@ fun rememberIdColor( @Composable fun ServerIconCard( server: JellyfinServer, + serverVersionSupported: ServerVersionSupported, connectionStatus: ServerConnectionStatus, isCurrentServer: Boolean, onClick: () -> Unit, @@ -166,28 +167,8 @@ fun ServerIconCard( contentAlignment = Alignment.Center, ) { // Show connection status indicator or server name/letter - when (connectionStatus) { - is ServerConnectionStatus.Success -> { - // Show server name/letter - - Text( - text = displayText, - style = - MaterialTheme.typography.displayLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - - ServerConnectionStatus.Pending -> { - CircularProgress( - modifier = Modifier.size(cardSize * 0.4f), - ) - } - - is ServerConnectionStatus.Error -> { + when { + connectionStatus is ServerConnectionStatus.Error || serverVersionSupported != ServerVersionSupported.SUPPORTED -> { // Show warning icon with server letter below Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -195,7 +176,7 @@ fun ServerIconCard( ) { Icon( imageVector = Icons.Default.Warning, - contentDescription = connectionStatus.message, + contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, tint = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.size(cardSize * 0.3f), ) @@ -210,6 +191,26 @@ fun ServerIconCard( ) } } + + connectionStatus is ServerConnectionStatus.Success -> { + // Show server name/letter + + Text( + text = displayText, + style = + MaterialTheme.typography.displayLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + connectionStatus == ServerConnectionStatus.Pending -> { + CircularProgress( + modifier = Modifier.size(cardSize * 0.4f), + ) + } } } } 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 d975fc29..c25fe776 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 @@ -1,5 +1,14 @@ package com.github.damontecres.wholphin.ui.setup +import android.Manifest +import android.content.res.Resources +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,12 +39,16 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources 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 import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -55,8 +68,10 @@ import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import timber.log.Timber @Composable fun SwitchServerContent( @@ -85,8 +100,10 @@ private fun SwitchServerContentInternal( viewModel: SwitchServerViewModel, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current var showAddServer by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } + var focusedIndex by rememberInt(0) Box( modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { @@ -125,8 +142,13 @@ private fun SwitchServerContentInternal( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (state.servers.isNotEmpty()) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(state.loading) { + val state = state + if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { + firstServerFocus.tryRequestFocus() + } else if (state.loading == LoadingState.Pending) { + firstServerFocus.tryRequestFocus() + } } LazyRow( horizontalArrangement = Arrangement.spacedBy(24.dp), @@ -141,6 +163,7 @@ private fun SwitchServerContentInternal( ServerIconCard( server = server.server, connectionStatus = server.status, + serverVersionSupported = server.versionSupported, isCurrentServer = false, // TODO: Determine current server if needed onClick = { when (server.status) { @@ -162,10 +185,13 @@ private fun SwitchServerContentInternal( }, allowDelete = true, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = index + }.ifElse( + index == 0, + Modifier.focusRequester(firstServerFocus), + ), ) } // Add Server card - always rightmost @@ -173,10 +199,13 @@ private fun SwitchServerContentInternal( AddServerCard( onClick = { showAddServer = true }, modifier = - Modifier.ifElse( - state.servers.isEmpty(), - Modifier.focusRequester(firstServerFocus), - ), + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = -1 + }.ifElse( + state.servers.isEmpty(), + Modifier.focusRequester(firstServerFocus), + ), ) } } @@ -191,6 +220,37 @@ private fun SwitchServerContentInternal( ) } + val errorMessage = + remember(resources, focusedIndex, state.servers) { + val server = state.servers.getOrNull(focusedIndex) + serverErrorMessage(server, resources) + } + AnimatedContent( + targetState = errorMessage, + transitionSpec = { + slideInVertically { it / 2 } togetherWith slideOutVertically { it } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { message -> + + Text( + text = message ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } + // Delete server dialog showDeleteDialog?.let { server -> DialogPopup( @@ -223,191 +283,229 @@ private fun SwitchServerContentInternal( } if (showAddServer) { - var showEnterAddress by remember { mutableStateOf(false) } + AddServerDialog( + onDismissRequest = { showAddServer = false }, + viewModel = viewModel, + ) + } + } +} - LaunchedEffect(Unit) { - viewModel.clearAddServerState() - if (!showEnterAddress) { - viewModel.discoverServers() - } +@Composable +private fun AddServerDialog( + onDismissRequest: () -> Unit, + viewModel: SwitchServerViewModel, +) { + val state by viewModel.state.collectAsState() + var showEnterAddress by remember { mutableStateOf(false) } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + viewModel.discoverServers() + } else { + showEnterAddress = true } + } - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) - val filteredDiscoveredServers = - remember(state.discoveredServers) { - val seenUrls = mutableSetOf<String>() - state.discoveredServers.filter { server -> - val normalizedUrl = server.url.lowercase().trim() - if (normalizedUrl in seenUrls) { - false // Duplicate, filter it out - } else { - seenUrls.add(normalizedUrl) - true // First occurrence, keep it + LaunchedEffect(Unit) { + viewModel.clearAddServerState() + if (!showEnterAddress) { + if (viewModel.hasPermission) { + viewModel.discoverServers() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + permissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } else { + Timber.w( + "No ACCESS_LOCAL_NETWORK permission, but API <%s: %s", + Build.VERSION_CODES.CINNAMON_BUN, + Build.VERSION.SDK_INT, + ) + } + } + } + + val firstDiscoveredServerFocusRequester = remember { FocusRequester() } + + // Default focus to first discovered server if available + LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) { + if (!showEnterAddress && state.discoveredServers.isNotEmpty()) { + firstDiscoveredServerFocusRequester.tryRequestFocus() + } + } + + BasicDialog( + onDismissRequest = { + showEnterAddress = false + viewModel.clearAddServerState() + onDismissRequest.invoke() + }, + properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = 10.dp, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(.4f), + ) { + if (!showEnterAddress) { + // Show discovered servers first + Text( + text = stringResource(R.string.discovered_servers), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (state.discoveredServers.isEmpty()) { + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + ) { + items( + state.discoveredServers.size, + key = { state.discoveredServers[it].url }, + ) { index -> + val server = state.discoveredServers[index] + val focusRequester = + if (index == 0) { + firstDiscoveredServerFocusRequester + } else { + remember { FocusRequester() } + } + + ListItem( + enabled = true, + selected = false, + headlineContent = { + Text( + text = + server.name?.ifBlank { null } + ?: server.url, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = { + viewModel.addServer(server.url, showToast = true) + }, + modifier = Modifier.focusRequester(focusRequester), + ) } } } - val firstDiscoveredServerFocusRequester = remember { FocusRequester() } - - // Default focus to first discovered server if available - LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) { - if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) { - firstDiscoveredServerFocusRequester.tryRequestFocus() + TextButton( + onClick = { + showEnterAddress = true + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(text = stringResource(R.string.enter_server_address)) } - } + } else { + // Show enter server address form + val addServerState = state.addServerState + var url by remember { mutableStateOf("") } + val submit = { + viewModel.addServer(url, showToast = false) + } + val textBoxFocusRequester = remember { FocusRequester() } - BasicDialog( - onDismissRequest = { - showAddServer = false - showEnterAddress = false + LaunchedEffect(Unit) { + textBoxFocusRequester.tryRequestFocus() + } + LaunchedEffect(url) { viewModel.clearAddServerState() - }, - properties = DialogProperties(usePlatformDefaultWidth = false), - elevation = 10.dp, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + } + + Text( + text = stringResource(R.string.enter_server_url), + ) + EditTextBox( + value = url, + onValueChange = { url = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { submit.invoke() }, + ), modifier = Modifier - .padding(16.dp) - .fillMaxWidth(.4f), + .testTag("server_url_text") + .focusRequester(textBoxFocusRequester) + .fillMaxWidth(), + ) + when (val st = addServerState) { + is LoadingState.Error -> { + Text( + text = + st.message ?: st.exception?.localizedMessage + ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } + TextButton( + onClick = { submit.invoke() }, + enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, + modifier = Modifier, ) { - if (!showEnterAddress) { - // Show discovered servers first - Text( - text = stringResource(R.string.discovered_servers), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else if (filteredDiscoveredServers.isEmpty()) { - Text( - text = stringResource(R.string.no_servers_found), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 300.dp), - ) { - items( - filteredDiscoveredServers.size, - key = { filteredDiscoveredServers[it].url }, - ) { index -> - val server = filteredDiscoveredServers[index] - val focusRequester = - if (index == 0) { - firstDiscoveredServerFocusRequester - } else { - remember { FocusRequester() } - } - - ListItem( - enabled = true, - selected = false, - headlineContent = { - Text( - text = - server.name?.ifBlank { null } - ?: server.url, - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - text = server.url, - style = MaterialTheme.typography.bodyMedium, - ) - }, - onClick = { - viewModel.addServer(server.url) - }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - } - - TextButton( - onClick = { - showEnterAddress = true - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text(text = stringResource(R.string.enter_server_address)) - } + if (addServerState == LoadingState.Loading) { + CircularProgress(Modifier.size(32.dp)) } else { - // Show enter server address form - val addServerState = state.addServerState - var url by remember { mutableStateOf("") } - val submit = { - viewModel.addServer(url) - } - val textBoxFocusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - textBoxFocusRequester.tryRequestFocus() - } - - Text( - text = stringResource(R.string.enter_server_url), - ) - EditTextBox( - value = url, - onValueChange = { url = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { submit.invoke() }, - ), - modifier = - Modifier - .testTag("server_url_text") - .focusRequester(textBoxFocusRequester) - .fillMaxWidth(), - ) - when (val st = addServerState) { - is LoadingState.Error -> { - Text( - text = - st.message ?: st.exception?.localizedMessage - ?: "An error occurred", - color = MaterialTheme.colorScheme.error, - ) - } - - else -> {} - } - TextButton( - onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, - modifier = Modifier, - ) { - if (addServerState == LoadingState.Loading) { - CircularProgress(Modifier.size(32.dp)) - } else { - Text(text = stringResource(R.string.submit)) - } - } + Text(text = stringResource(R.string.submit)) } } } } } } + +fun serverErrorMessage( + server: ServerState?, + resources: Resources, +): String? = + when { + server?.status is ServerConnectionStatus.Error -> { + server.status.message + } + + server?.status is ServerConnectionStatus.Success && + server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { + resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" + } + + server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { + resources.getString(R.string.server_version_not_supported) + } + + else -> { + null + } + } 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 9c28aff8..53190665 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 @@ -1,10 +1,15 @@ package com.github.damontecres.wholphin.ui.setup +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -21,9 +26,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -43,6 +50,17 @@ class SwitchServerViewModel private val _state = MutableStateFlow(SwitchServerState()) val state: StateFlow<SwitchServerState> = _state + val hasPermission: Boolean + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_LOCAL_NETWORK, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + fun clearAddServerState() { _state.update { it.copy(addServerState = LoadingState.Pending) } } @@ -73,11 +91,30 @@ class SwitchServerViewModel server: JellyfinServer, result: ServerConnectionStatus, ) { + val supported = + if (result is ServerConnectionStatus.Success) { + val serverVersion = + result.systemInfo.version?.let { ServerVersion.fromString(it) } + if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED + } else { + ServerVersionSupported.SUPPORTED + } + } else { + ServerVersionSupported.UNKNOWN + } _state.update { val servers = it.servers.toMutableList().apply { val index = indexOfFirst { it.server.id == server.id } - set(index, get(index).copy(server = server, status = result)) + set( + index, + get(index).copy( + server = server, + status = result, + versionSupported = supported, + ), + ) } it.copy(servers = servers) } @@ -113,6 +150,14 @@ class SwitchServerViewModel .getPublicSystemInfo() .content ServerConnectionStatus.Success(systemInfo) + } catch (ex: TimeoutException) { + Timber.w(ex, "timeout checking server ${server.url}") + val cause = ex.cause + if (cause is java.net.ConnectException) { + ServerConnectionStatus.Error("${cause.localizedMessage}") + } else { + ServerConnectionStatus.Error(ex.localizedMessage) + } } catch (ex: Exception) { Timber.w(ex, "Error checking server ${server.url}") ServerConnectionStatus.Error(ex.localizedMessage) @@ -134,12 +179,15 @@ class SwitchServerViewModel serverRepository.addAndChangeServer(updatedServer) navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } else if (result is ServerConnectionStatus.Error) { - showToast(context, "Error connecting: $${result.message}") + showToast(context, "Error connecting: ${result.message}") } } } - fun addServer(inputUrl: String) { + fun addServer( + inputUrl: String, + showToast: Boolean, + ) { _state.update { it.copy(addServerState = LoadingState.Loading) } viewModelScope.launchIO { try { @@ -165,7 +213,9 @@ class SwitchServerViewModel _state.update { it.copy(addServerState = LoadingState.Success) } navigationManager.navigateTo(SetupDestination.UserList(server)) } else { - _state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) } + val msg = "Server returned invalid response" + _state.update { it.copy(addServerState = LoadingState.Error(msg)) } + if (showToast) showToast(context, msg) } } else { Timber.w("Error connecting with %s: %s", inputUrl, scores) @@ -194,10 +244,12 @@ class SwitchServerViewModel } val message = "Error, tried addresses:\n$errors" _state.update { it.copy(addServerState = LoadingState.Error(message)) } + if (showToast) showToast(context, message) } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $inputUrl") _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } + if (showToast) showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -226,6 +278,9 @@ class SwitchServerViewModel .toMutableList() .apply { add(jellyfinServer) + }.distinctBy { + // Filter out duplicates within the discovered servers list (same URL appearing multiple times) + it.url.lowercase().trim() }, ) } @@ -245,4 +300,11 @@ data class ServerState( val server: JellyfinServer, val status: ServerConnectionStatus = ServerConnectionStatus.Pending, val quickConnect: Boolean = false, + val versionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +enum class ServerVersionSupported { + SUPPORTED, + NOT_SUPPORTED, + UNKNOWN, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 2d9158de..86cf718d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -25,11 +25,13 @@ 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.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -84,6 +86,24 @@ fun SwitchUserContent( showAddUser = false } + fun trySwitchUser(user: JellyfinUser) { + val result = viewModel.trySwitchUser(user) + scope.launch { + when (val r = result.await()) { + is SwitchUserResult.Error -> { + Toast.makeText(context, r.errorMessage, Toast.LENGTH_LONG).show() + if (r.showLogin) { + showAddUserDialog(user) + } + } + + SwitchUserResult.Success -> { + // no-op, view model will navigate + } + } + } + } + LaunchedEffect(state.switchUserState) { if (!showAddUser) { when (val s = state.switchUserState) { @@ -146,13 +166,7 @@ fun SwitchUserContent( } else if (user.hasPin) { switchUserWithPin = user } else { - val result = viewModel.trySwitchUser(user) - scope.launch { - result.await()?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - showAddUserDialog(user) - } - } + trySwitchUser(user) } }, onAddUser = { @@ -169,6 +183,37 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } + when (state.serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> {} + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + val resources = LocalResources.current + val message = + remember(resources) { + if (state.serverVersion.isNotNullOrBlank()) { + resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}" + } else { + resources.getString(R.string.server_version_not_supported) + ": " + + resources.getString(R.string.unknown) + } + } + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } + } } } } @@ -363,14 +408,7 @@ fun SwitchUserContent( }, onTextChange = { if (it == user.pin) { - val result = viewModel.trySwitchUser(user) - scope.launch { - result.await()?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - showAddUserDialog(user) - switchUserWithPin = null - } - } + trySwitchUser(user) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index fce4a01c..e1a6dd96 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -1,7 +1,10 @@ package com.github.damontecres.wholphin.ui.setup +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -13,13 +16,14 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -33,7 +37,9 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.imageApi 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.ServerVersion import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult import timber.log.Timber @@ -43,6 +49,7 @@ import kotlin.time.Duration.Companion.seconds class SwitchUserViewModel @AssistedInject constructor( + @param:ApplicationContext private val context: Context, val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, @@ -78,10 +85,13 @@ class SwitchUserViewModel _state.update { SwitchUserState() } try { val serverUsers = getUsers() + val (serverVersion, supported) = checkServerVersion() _state.update { it.copy( loading = LoadingState.Success, users = serverUsers, + serverVersion = serverVersion, + serverVersionSupported = supported, ) } } catch (ex: Exception) { @@ -118,21 +128,39 @@ class SwitchUserViewModel } } - fun trySwitchUser(user: JellyfinUser): Deferred<String?> = - viewModelScope.async(Dispatchers.IO) { + fun trySwitchUser(user: JellyfinUser): Deferred<SwitchUserResult> = + viewModelScope.async(WholphinDispatchers.IO) { try { val current = serverRepository.changeUser(server, user) + navigationManager.reloadHome() setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - null + SwitchUserResult.Success } catch (ex: InvalidStatusException) { - if (ex.status == 401) { - "Credentials expired, please login in again" - } else { - ex.localizedMessage + when (ex.status) { + 401 -> { + Timber.w(ex, "Error switching user 401") + SwitchUserResult.Error( + context.getString(R.string.login_credentials_expired), + true, + ) + } + + 403 -> { + Timber.w(ex, "Error switching user 403") + SwitchUserResult.Error( + context.getString(R.string.login_not_authorized), + false, + ) + } + + else -> { + Timber.e(ex, "Error switching user") + SwitchUserResult.Error(ex.localizedMessage, true) + } } } catch (ex: Exception) { Timber.e(ex, "Error switching user") - ex.localizedMessage + SwitchUserResult.Error(ex.localizedMessage, true) } } @@ -234,7 +262,7 @@ class SwitchUserViewModel } private suspend fun getUsers(): List<JellyfinUserAndImage> = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { val api = jellyfin.createApi(server.url) val knownUsers = serverDao @@ -272,6 +300,19 @@ class SwitchUserViewModel knownUsers + publicUsers } + private suspend fun checkServerVersion(): Pair<String?, ServerVersionSupported> { + val api = jellyfin.createApi(server.url) + val systemInfo by api.systemApi.getPublicSystemInfo() + val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } + return if (serverVersion == null) { + systemInfo.version to ServerVersionSupported.UNKNOWN + } else if (serverVersion < WholphinApplication.minimumServerVersion) { + systemInfo.version to ServerVersionSupported.NOT_SUPPORTED + } else { + systemInfo.version to ServerVersionSupported.SUPPORTED + } + } + private fun setError( msg: String? = null, ex: Exception? = null, @@ -300,4 +341,18 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, + val serverVersion: String? = null, + val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) + +/** + * Result of trying to switch users via [SwitchUserViewModel.trySwitchUser] + */ +sealed interface SwitchUserResult { + data object Success : SwitchUserResult + + data class Error( + val errorMessage: String?, + val showLogin: Boolean, + ) : SwitchUserResult +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 9a988753..91dc2543 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -408,8 +408,11 @@ fun SlideshowPage( ImageRequest .Builder(LocalContext.current) .data(imageState.url) - .size(Size.ORIGINAL) - .transitionFactory(CrossFadeFactory(750.milliseconds)) + .apply { + // Fixes images noting being full size when resolutions change + // See: https://github.com/damontecres/Wholphin/pull/1089 + if (isZoomed) size(Size.ORIGINAL) + }.transitionFactory(CrossFadeFactory(750.milliseconds)) .useExistingImageAsPlaceholder(true) .build(), contentDescription = null, 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 index 9744c9de..e74ffdf2 100644 --- 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 @@ -6,6 +6,9 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.Player +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.size.Size import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.ServerRepository @@ -27,13 +30,13 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -111,7 +114,7 @@ class SlideshowViewModel val playerCreation = playerFactory.createVideoPlayer( backend = PlayerBackend.EXO_PLAYER, - appPreferences.playbackPreferences, + appPreferences, ) player = playerCreation.player player.addListener(this@SlideshowViewModel) @@ -172,7 +175,7 @@ class SlideshowViewModel val size = state.value.items.size val newPosition = state.value.position + 1 return if (newPosition < size) { - updatePosition(newPosition) + updatePosition(newPosition, true) true } else { false @@ -182,14 +185,23 @@ class SlideshowViewModel fun previousImage(): Boolean { val newPosition = state.value.position - 1 return if (newPosition >= 0) { - updatePosition(newPosition) + updatePosition(newPosition, false) true } else { false } } - fun updatePosition(position: Int): Job? = + /** + * Update image state to the specified position + * + * @param position the index to use + * @param isNext whether moving to the next or previous image for prefetching purposes + */ + fun updatePosition( + position: Int, + isNext: Boolean = true, + ): Job? = (state.value.items as? ApiRequestPager<*>)?.let { pager -> viewModelScope.launchIO { try { @@ -286,6 +298,31 @@ class SlideshowViewModel ) } } + try { + // If there is a next/previous image, fetch it into the image cache + val nextIndex = position + if (isNext) 1 else -1 + if (nextIndex in pager.indices) { + pager.getBlocking(nextIndex)?.let { nextImage -> + if (nextImage.data.mediaType == MediaType.PHOTO) { + val url = api.libraryApi.getDownloadUrl(nextImage.id) + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .build() + context.imageLoader.enqueue(request) + Timber.v( + "Prefetching index=%s, id=%s", + nextIndex, + nextImage.id, + ) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error preloading image") + } } } @@ -339,7 +376,7 @@ class SlideshowViewModel fun pulseSlideshow() = pulseSlideshow(slideshowDelay) fun pulseSlideshow(milliseconds: Long) { - Timber.v("pulseSlideshow $milliseconds") +// Timber.v("pulseSlideshow $milliseconds") slideshowJob?.cancel() slideshowJob = viewModelScope @@ -377,7 +414,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for image %s", it.image.id) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", @@ -406,7 +443,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt index 53c10bd2..8ae95b25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt @@ -7,10 +7,12 @@ import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.theme.colors.BlueThemeColors import com.github.damontecres.wholphin.ui.theme.colors.BoldBlueThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.BrownThemeColors import com.github.damontecres.wholphin.ui.theme.colors.GreenThemeColors import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors val LocalTheme = compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE } @@ -23,6 +25,8 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.ORANGE -> OrangeThemeColors AppThemeColors.OLED_BLACK -> OledThemeColors AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors + AppThemeColors.RED -> RedThemeColors + AppThemeColors.BROWN -> BrownThemeColors AppThemeColors.UNRECOGNIZED -> PurpleThemeColors } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 70d30254..f5c83ce1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -19,6 +20,7 @@ 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.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem @@ -42,26 +44,37 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2500dp", + device = "spec:width=1200dp,height=3000dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @Composable private fun ThemePreview() { + val themes = AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED } Column { LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED }) { + items(themes) { ThemeExample(it) } } } } +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + color = Color.White, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) +} + @Composable private fun ThemeExample(theme: AppThemeColors) { val source = remember { PreviewInteractionSource() } @@ -71,10 +84,13 @@ private fun ThemeExample(theme: AppThemeColors) { Text( text = theme.toString(), color = Color.White, + modifier = Modifier.padding(8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(8.dp), ) { + SectionTitle("Cards") Row( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, @@ -134,6 +150,7 @@ private fun ThemeExample(theme: AppThemeColors) { aspectRatio = AspectRatios.SQUARE, ) } + SectionTitle("Controls") Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SliderPreference( preference = AppPreference.AutoPlayNextDelay, @@ -191,6 +208,7 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + SectionTitle("Nav drawer") val navScope = NavScope(true) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -240,6 +258,7 @@ private fun ThemeExample(theme: AppThemeColors) { ) } } + SectionTitle("Playback controls") Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), @@ -271,6 +290,7 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + SectionTitle("List items") Column( modifier = Modifier.background(MaterialTheme.colorScheme.surface), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt new file mode 100644 index 00000000..04c050fd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +val BrownThemeColors = + object : ThemeColors { + val primaryLight = Color(0xFF8A5022) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFFFDCC6) + val onPrimaryContainerLight = Color(0xFF6D390C) + val secondaryLight = Color(0xFF755845) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFFFDCC6) + val onSecondaryContainerLight = Color(0xFF5B412F) + val tertiaryLight = Color(0xFF5F6135) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFE5E6AE) + val onTertiaryContainerLight = Color(0xFF47491F) + val errorLight = Color(0xFFBA1A1A) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD6) + val onErrorContainerLight = Color(0xFF93000A) + val backgroundLight = Color(0xFFFFF8F5) + val onBackgroundLight = Color(0xFF221A15) + val surfaceLight = Color(0xFFFFF8F5) + val onSurfaceLight = Color(0xFF221A15) + val surfaceVariantLight = Color(0xFFF3DED3) + val onSurfaceVariantLight = Color(0xFF52443B) + val outlineLight = Color(0xFF84746A) + val outlineVariantLight = Color(0xFFD7C3B7) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF382F29) + val inverseOnSurfaceLight = Color(0xFFFEEDE4) + val inversePrimaryLight = Color(0xFFFFB784) + val surfaceDimLight = Color(0xFFE7D7CE) + val surfaceBrightLight = Color(0xFFFFF8F5) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFFFF1EA) + val surfaceContainerLight = Color(0xFFFBEBE2) + val surfaceContainerHighLight = Color(0xFFF6E5DC) + val surfaceContainerHighestLight = Color(0xFFF0DFD6) + + val primaryDark = Color(0xFFFFB784) + val onPrimaryDark = Color(0xFF4F2500) + val primaryContainerDark = Color(0xFF6D390C) + val onPrimaryContainerDark = Color(0xFFFFDCC6) + val secondaryDark = Color(0xFFE4BFA8) + val onSecondaryDark = Color(0xFF422B1B) + val secondaryContainerDark = Color(0xFF5B412F) + val onSecondaryContainerDark = Color(0xFFFFDCC6) + val tertiaryDark = Color(0xFFC8CA94) + val onTertiaryDark = Color(0xFF30330B) + val tertiaryContainerDark = Color(0xFF47491F) + val onTertiaryContainerDark = Color(0xFFE5E6AE) + val errorDark = Color(0xFFFFB4AB) + val onErrorDark = Color(0xFF690005) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD6) + val backgroundDark = Color(0xFF19120D) + val onBackgroundDark = Color(0xFFF0DFD6) + val surfaceDark = Color(0xFF19120D) + val onSurfaceDark = Color(0xFFF0DFD6) + val surfaceVariantDark = Color(0xFF52443B) + val onSurfaceVariantDark = Color(0xFFD7C3B7) + val outlineDark = Color(0xFF9F8D83) + val outlineVariantDark = Color(0xFF52443B) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFF0DFD6) + val inverseOnSurfaceDark = Color(0xFF382F29) + val inversePrimaryDark = Color(0xFF6D3B07) + val surfaceDimDark = Color(0xFF19120D) + val surfaceBrightDark = Color(0xFF413731) + val surfaceContainerLowestDark = Color(0xFF140D08) + val surfaceContainerLowDark = Color(0xFF221A15) + val surfaceContainerDark = Color(0xFF261E18) + val surfaceContainerHighDark = Color(0xFF312822) + val surfaceContainerHighestDark = Color(0xFF3C332D) + + override val lightSchemeMaterial: ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + ) + + override val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + border = inversePrimaryLight, + ) + + override val darkSchemeMaterial = + androidx.compose.material3.darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + ) + + override val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + border = inversePrimaryDark, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt new file mode 100644 index 00000000..ecdcbdf4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +val RedThemeColors = + object : ThemeColors { + val primaryLight = Color(0xFF904A42) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFFFDAD5) + val onPrimaryContainerLight = Color(0xFF73342C) + val secondaryLight = Color(0xFF775652) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFFFFDAD5) + val onSecondaryContainerLight = Color(0xFF5D3F3B) + val tertiaryLight = Color(0xFF715C2E) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFFDDFA6) + val onTertiaryContainerLight = Color(0xFF574419) + val errorLight = Color(0xFFBA1A1A) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD6) + val onErrorContainerLight = Color(0xFF93000A) + val backgroundLight = Color(0xFFFFF8F7) + val onBackgroundLight = Color(0xFF231918) + val surfaceLight = Color(0xFFFFF8F7) + val onSurfaceLight = Color(0xFF231918) + val surfaceVariantLight = Color(0xFFF5DDDA) + val onSurfaceVariantLight = Color(0xFF534341) + val outlineLight = Color(0xFF857370) + val outlineVariantLight = Color(0xFFD8C2BE) + val scrimLight = Color(0xFF000000) + val inverseSurfaceLight = Color(0xFF392E2C) + val inverseOnSurfaceLight = Color(0xFFFFEDEA) + val inversePrimaryLight = Color(0xFFFFB4AA) + val surfaceDimLight = Color(0xFFE8D6D4) + val surfaceBrightLight = Color(0xFFFFF8F7) + val surfaceContainerLowestLight = Color(0xFFFFFFFF) + val surfaceContainerLowLight = Color(0xFFFFF0EE) + val surfaceContainerLight = Color(0xFFFCEAE7) + val surfaceContainerHighLight = Color(0xFFF7E4E2) + val surfaceContainerHighestLight = Color(0xFFF1DEDC) + + val primaryDark = Color(0xFFFFB4AA) + val onPrimaryDark = Color(0xFF561E18) + val primaryContainerDark = Color(0xFF73342C) + val onPrimaryContainerDark = Color(0xFFFFDAD5) + val secondaryDark = Color(0xFFE7BDB7) + val onSecondaryDark = Color(0xFF442926) + val secondaryContainerDark = Color(0xFF5D3F3B) + val onSecondaryContainerDark = Color(0xFFFFDAD5) + val tertiaryDark = Color(0xFFDFC38C) + val onTertiaryDark = Color(0xFF3F2E04) + val tertiaryContainerDark = Color(0xFF574419) + val onTertiaryContainerDark = Color(0xFFFDDFA6) + val errorDark = Color(0xFFFFB4AB) + val onErrorDark = Color(0xFF690005) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD6) + val backgroundDark = Color(0xFF1A1110) + val onBackgroundDark = Color(0xFFF1DEDC) + val surfaceDark = Color(0xFF1A1110) + val onSurfaceDark = Color(0xFFF1DEDC) + val surfaceVariantDark = Color(0xFF534341) + val onSurfaceVariantDark = Color(0xFFD8C2BE) + val outlineDark = Color(0xFFA08C8A) + val outlineVariantDark = Color(0xFF534341) + val scrimDark = Color(0xFF000000) + val inverseSurfaceDark = Color(0xFFF1DEDC) + val inverseOnSurfaceDark = Color(0xFF392E2C) + val inversePrimaryDark = Color(0xFFC0000C) + val surfaceDimDark = Color(0xFF1A1110) + val surfaceBrightDark = Color(0xFF423735) + val surfaceContainerLowestDark = Color(0xFF140C0B) + val surfaceContainerLowDark = Color(0xFF231918) + val surfaceContainerDark = Color(0xFF271D1C) + val surfaceContainerHighDark = Color(0xFF322826) + val surfaceContainerHighestDark = Color(0xFF3D3231) + + override val lightSchemeMaterial: ColorScheme = + androidx.compose.material3.lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + ) + + override val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + border = inversePrimaryLight, + ) + + override val darkSchemeMaterial = + androidx.compose.material3.darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + ) + + override val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + border = inversePrimaryDark, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt deleted file mode 100644 index a39cae25..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter -import com.github.damontecres.wholphin.data.filter.FavoriteFilter -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter -import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.StudioFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.studiosApi -import org.jellyfin.sdk.api.client.extensions.yearsApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import timber.log.Timber -import java.util.TreeSet -import java.util.UUID - -object FilterUtils { - /** - * Gets the possible values for a filter - * - * For example, the possible genres in the parent ID - */ - suspend fun getFilterOptionValues( - api: ApiClient, - userId: UUID?, - parentId: UUID?, - filterOption: ItemFilterBy<*>, - ): List<FilterValueOption> = - withContext(Dispatchers.IO) { - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = parentId, - userId = userId, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - StudioFilter -> { - api.studiosApi - .getStudios( - parentId = parentId, - userId = userId, - includeItemTypes = listOf(BaseItemKind.SERIES), - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = parentId, - userId = userId, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet<Int>() - api.yearsApi - .getYears( - parentId = parentId, - userId = userId, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index 213dc725..2639a919 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -7,8 +7,9 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.github.damontecres.wholphin.ui.getTimeFormatter -import kotlinx.coroutines.Dispatchers +import androidx.compose.ui.platform.LocalContext +import com.github.damontecres.wholphin.ui.formatTime +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -25,19 +26,20 @@ data class Clock( */ val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()), /** - * The current time formatted as a string with [getTimeFormatter] + * The current time formatted as a string; populated by [ProvideLocalClock] via [formatTime]. */ - val timeString: MutableState<String> = mutableStateOf(getTimeFormatter().format(now.value)), + val timeString: MutableState<String> = mutableStateOf(""), ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { + val context = LocalContext.current val clock = remember { Clock() } - LaunchedEffect(Unit) { - withContext(Dispatchers.Default) { + LaunchedEffect(context) { + withContext(WholphinDispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = getTimeFormatter().format(now) + val time = formatTime(context, now) clock.now.value = now clock.timeString.value = time delay(2_000) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt index 6c3e7bca..c63c4067 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt @@ -93,3 +93,13 @@ data class ResProviderStringProvider( @NonRestartableComposable override fun getString(): String = stringResource(stringResId, argProvider.getString()) } + +@Serializable +data class ConcatStringProvider( + val separator: String, + val providers: List<StringProvider>, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = providers.map { it.getString() }.joinToString(separator) +} 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 32b66d04..750ba711 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 @@ -5,8 +5,12 @@ import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.artistsApi import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi @@ -18,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -107,7 +112,8 @@ class ApiRequestPager<T>( /** * Specifies how a [RequestPager] should prepare and execute API calls */ -interface RequestHandler<T> { +@Serializable +sealed interface RequestHandler<T> { /** * Prepare the given request with the specified parameters (eg which page to fetch) */ @@ -125,248 +131,291 @@ interface RequestHandler<T> { api: ApiClient, request: T, ): Response<BaseItemDtoQueryResult> + + /** + * Count the total number of possible results for the request. + * + * [request] should be efficient and use `limit = 0` and `enableTotalRecordCount = true` + */ + suspend fun countMatching( + api: ApiClient, + request: T, + ): Int { + val result by execute(api, request) + return result.totalRecordCount + } } @Serializable -val GetItemsRequestHandler = - object : RequestHandler<GetItemsRequest> { - override fun prepare( - request: GetItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetItemsRequestHandler : RequestHandler<GetItemsRequest> { + override fun prepare( + request: GetItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) +} @Serializable -val GetEpisodesRequestHandler = - object : RequestHandler<GetEpisodesRequest> { - override fun prepare( - request: GetEpisodesRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetEpisodesRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +object GetEpisodesRequestHandler : RequestHandler<GetEpisodesRequest> { + override fun prepare( + request: GetEpisodesRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetEpisodesRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetEpisodesRequest, - ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) - } + override suspend fun execute( + api: ApiClient, + request: GetEpisodesRequest, + ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) +} @Serializable -val GetResumeItemsRequestHandler = - object : RequestHandler<GetResumeItemsRequest> { - override fun prepare( - request: GetResumeItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetResumeItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetResumeItemsRequestHandler : RequestHandler<GetResumeItemsRequest> { + override fun prepare( + request: GetResumeItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetResumeItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetResumeItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetResumeItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) +} @Serializable -val GetNextUpRequestHandler = - object : RequestHandler<GetNextUpRequest> { - override fun prepare( - request: GetNextUpRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetNextUpRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetNextUpRequestHandler : RequestHandler<GetNextUpRequest> { + override fun prepare( + request: GetNextUpRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetNextUpRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetNextUpRequest, - ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) - } + override suspend fun execute( + api: ApiClient, + request: GetNextUpRequest, + ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) +} @Serializable -val GetSuggestionsRequestHandler = - object : RequestHandler<GetSuggestionsRequest> { - override fun prepare( - request: GetSuggestionsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetSuggestionsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetSuggestionsRequestHandler : RequestHandler<GetSuggestionsRequest> { + override fun prepare( + request: GetSuggestionsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetSuggestionsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetSuggestionsRequest, - ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) - } + override suspend fun execute( + api: ApiClient, + request: GetSuggestionsRequest, + ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) +} @Serializable -val GetPlaylistItemsRequestHandler = - object : RequestHandler<GetPlaylistItemsRequest> { - override fun prepare( - request: GetPlaylistItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPlaylistItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +object GetPlaylistItemsRequestHandler : RequestHandler<GetPlaylistItemsRequest> { + override fun prepare( + request: GetPlaylistItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPlaylistItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetPlaylistItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) - } + override suspend fun execute( + api: ApiClient, + request: GetPlaylistItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) +} @Serializable -val GetGenresRequestHandler = - object : RequestHandler<GetGenresRequest> { - override fun prepare( - request: GetGenresRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetGenresRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +object GetGenresRequestHandler : RequestHandler<GetGenresRequest> { + override fun prepare( + request: GetGenresRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetGenresRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetGenresRequest, - ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) - } - -val GetProgramsDtoHandler = - object : RequestHandler<GetProgramsDto> { - override fun prepare( - request: GetProgramsDto, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetProgramsDto = - request.copy( - startIndex = startIndex, - limit = limit, - ) - - override suspend fun execute( - api: ApiClient, - request: GetProgramsDto, - ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) - } + override suspend fun execute( + api: ApiClient, + request: GetGenresRequest, + ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) +} @Serializable -val GetPersonsHandler = - object : RequestHandler<GetPersonsRequest> { - override fun prepare( - request: GetPersonsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPersonsRequest = - request.copy( +object GetProgramsDtoHandler : RequestHandler<GetProgramsDto> { + override fun prepare( + request: GetProgramsDto, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetProgramsDto = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) +} + +@Serializable +object GetPersonsHandler : RequestHandler<GetPersonsRequest> { + override fun prepare( + request: GetPersonsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPersonsRequest = + request.copy( // startIndex = startIndex, - limit = limit, - ) + limit = limit, + ) - override suspend fun execute( - api: ApiClient, - request: GetPersonsRequest, - ): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) - } + override suspend fun execute( + api: ApiClient, + request: GetPersonsRequest, + ): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) +} -val GetStudiosRequestHandler = - object : RequestHandler<GetStudiosRequest> { - override fun prepare( - request: GetStudiosRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetStudiosRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +@Serializable +object GetStudiosRequestHandler : RequestHandler<GetStudiosRequest> { + override fun prepare( + request: GetStudiosRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetStudiosRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetStudiosRequest, - ): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request) - } + override suspend fun execute( + api: ApiClient, + request: GetStudiosRequest, + ): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request) +} -val GetRecordingsRequestHandler = - object : RequestHandler<GetRecordingsRequest> { - override fun prepare( - request: GetRecordingsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetRecordingsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +@Serializable +object GetRecordingsRequestHandler : RequestHandler<GetRecordingsRequest> { + override fun prepare( + request: GetRecordingsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetRecordingsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) - override suspend fun execute( - api: ApiClient, - request: GetRecordingsRequest, - ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request) - } + override suspend fun execute( + api: ApiClient, + request: GetRecordingsRequest, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request) +} -val GetLiveTvChannelsRequestHandler = - object : RequestHandler<GetLiveTvChannelsRequest> { - override fun prepare( - request: GetLiveTvChannelsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetLiveTvChannelsRequest = - request.copy( - startIndex = startIndex, - limit = limit, +@Serializable +object GetLiveTvChannelsRequestHandler : RequestHandler<GetLiveTvChannelsRequest> { + override fun prepare( + request: GetLiveTvChannelsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetLiveTvChannelsRequest = + request.copy( + startIndex = startIndex, + limit = limit, // enableTotalRecordCount = enableTotalRecordCount, - ) + ) - override suspend fun execute( - api: ApiClient, - request: GetLiveTvChannelsRequest, - ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request) + override suspend fun execute( + api: ApiClient, + request: GetLiveTvChannelsRequest, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request) +} + +@Serializable +object GetArtistsHandler : RequestHandler<GetArtistsRequest> { + override fun prepare( + request: GetArtistsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetArtistsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetArtistsRequest, + ): Response<BaseItemDtoQueryResult> = api.artistsApi.getArtists((request)) +} + +val requestSerializersModule = + SerializersModule { + polymorphic(Any::class) { + subclass(GetItemsRequest::class) + subclass(GetEpisodesRequest::class) + subclass(GetResumeItemsRequest::class) + subclass(GetNextUpRequest::class) + subclass(GetSuggestionsRequest::class) + subclass(GetPlaylistItemsRequest::class) + subclass(GetGenresRequest::class) + subclass(GetProgramsDto::class) + subclass(GetPersonsRequest::class) + subclass(GetStudiosRequest::class) + subclass(GetRecordingsRequest::class) + subclass(GetLiveTvChannelsRequest::class) + subclass(GetArtistsHandler::class) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt index 08207e53..80272719 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/BlockingList.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.util import java.util.function.IntFunction import java.util.function.Predicate +import kotlin.random.Random /** * A [List] which has function that will wait for a result @@ -17,6 +18,11 @@ interface BlockingList<T> : List<T> { */ suspend fun indexOfBlocking(predicate: Predicate<T>): Int + suspend fun randomBlocking(): T { + val index = Random.nextInt(size) + return getBlocking(index) + } + companion object { /** * Create a [BlockingList] over a regular [List] 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 0c577571..f2078766 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 @@ -33,14 +33,7 @@ val supportedCollectionTypes = CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, CollectionType.MUSIC, - null, // Mixed - ) - -val supportedHomeCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, + CollectionType.PHOTOS, null, // Mixed ) @@ -53,4 +46,16 @@ val supportedPlayableTypes = BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + BaseItemKind.PLAYLIST, + ) + +val supportedShufflableTypes = + setOf( + BaseItemKind.SEASON, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + BaseItemKind.PLAYLIST, ) 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 index 3ea6bdf9..412130d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -1,7 +1,6 @@ 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 @@ -21,7 +20,7 @@ import kotlin.coroutines.CoroutineContext */ class CoroutineContextApiClient( private val client: ApiClient, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, ) : ApiClient() { override val baseUrl: String? get() = client.baseUrl @@ -65,7 +64,7 @@ class CoroutineContextApiClient( class CoroutineContextApiClientFactory( private val factory: OkHttpFactory, - private val coroutineContext: CoroutineContext = Dispatchers.IO, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, ) : ApiClientFactory, SocketConnectionFactory { override fun create( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt index cee890ce..ceb4f160 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -4,10 +4,16 @@ import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.discover.discoverGenreTitle +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable /** * A [RequestPager] for Seerr server queries @@ -45,6 +51,27 @@ enum class DiscoverRequestType( UNKNOWN(R.string.unknown), } +@Serializable +sealed interface DiscoverPagerType { + val title: StringProvider + + @Serializable + data class RequestType( + val type: DiscoverRequestType, + ) : DiscoverPagerType { + override val title: StringProvider = ResStringProvider(type.stringRes) + } + + @Serializable + data class Genre( + val genreId: Int, + val name: String, + val type: SeerrItemType, + ) : DiscoverPagerType { + override val title: StringProvider = discoverGenreTitle(name, type) + } +} + /** * Specifies how a [RequestPager] should prepare and execute API calls */ @@ -55,27 +82,29 @@ interface DiscoverRequestHandler<T> { ): QueryResult<T> } -val DiscoverTvRequestHandler = - object : DiscoverRequestHandler<TvResult> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<TvResult> = - api.api.searchApi.discoverTvGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } +class DiscoverTvRequestHandler( + val filter: DiscoverFilter = DiscoverFilter(), +) : DiscoverRequestHandler<TvResult> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<TvResult> = + filter.discoverTv(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } +} -val DiscoverMovieRequestHandler = - object : DiscoverRequestHandler<MovieResult> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<MovieResult> = - api.api.searchApi.discoverMoviesGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } +class DiscoverMovieRequestHandler( + val filter: DiscoverFilter = DiscoverFilter(), +) : DiscoverRequestHandler<MovieResult> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<MovieResult> = + filter.discoverMovies(api.api.searchApi, pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } +} val TrendingRequestHandler = object : DiscoverRequestHandler<SeerrSearchResult> { 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 f42eb6ac..7c618290 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 @@ -67,6 +67,7 @@ sealed interface HomeRowLoadingState { val items: List<BaseItem?>, val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), val rowType: HomeRowConfig? = null, + val showViewMore: Boolean = true, ) : HomeRowLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt index c618f6e5..e02a525e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.util +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import timber.log.Timber import java.lang.reflect.Field @@ -7,6 +9,7 @@ import java.lang.reflect.Field /** * Utility class to override private fields in media3's [SubtitleView] */ +@OptIn(UnstableApi::class) class Media3SubtitleOverride( val outlineThicknessPx: Float, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt deleted file mode 100644 index 46ec3606..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.github.damontecres.wholphin.util - -import com.github.damontecres.wholphin.preferences.UserPreferences -import java.util.UUID - -/** - * Functions to remember tabs choices for a library - */ -interface RememberTabManager { - /** - * If enabled, get the remembered tab index for the given item - */ - fun getRememberedTab( - preferences: UserPreferences, - itemId: UUID, - defaultTab: Int, - ): Int = getRememberedTab(preferences, itemId.toString(), defaultTab) - - /** - * If enabled, get the remembered tab index for the given item - */ - fun getRememberedTab( - preferences: UserPreferences, - itemId: String, - defaultTab: Int, - ): Int - - /** - * If enabled, save the remembered tab index for the given item - */ - fun saveRememberedTab( - preferences: UserPreferences, - itemId: UUID, - tabIndex: Int, - ) = saveRememberedTab(preferences, itemId.toString(), tabIndex) - - /** - * If enabled, save the remembered tab index for the given item - */ - fun saveRememberedTab( - preferences: UserPreferences, - itemId: String, - tabIndex: Int, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt index b478338b..10f0f64f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE import com.google.common.cache.CacheBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -25,7 +24,7 @@ import java.util.function.Predicate */ abstract class RequestPager<T>( protected val scope: CoroutineScope, - protected val pageSize: Int = DEFAULT_PAGE_SIZE, + val pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, ) : AbstractList<T?>(), BlockingList<T?> { @@ -85,7 +84,7 @@ abstract class RequestPager<T>( get() = totalCount private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { + scope.launch(ExceptionHandler() + WholphinDispatchers.IO) { fetchPageBlocking(position, false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt index 60dada9d..dd0d0b38 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt @@ -10,14 +10,20 @@ object SearchRelevance { fun score( item: BaseItem, query: String, + ): Int = score(item.name, item.type, query) + + fun score( + name: String?, + type: BaseItemKind, + query: String, ): Int { - val name = item.name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE + val name = name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE val q = query.lowercase(Locale.getDefault()) REGEX_PATTERN.find(query)?.let { match -> val pattern = match.groupValues[1] val caseInsensitive = match.groupValues[2] == "i" - return regexScore(item.name ?: "", pattern, caseInsensitive) + typeBonus(item.type) + return regexScore(name, pattern, caseInsensitive) + typeBonus(type) } return when { @@ -26,7 +32,7 @@ object SearchRelevance { name.contains(" $q") -> 200 name.contains(q) -> 300 else -> fuzzyScore(name, q) - } + typeBonus(item.type) + } + typeBonus(type) } private fun regexScore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 86ba1425..2ab4c2f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -3,11 +3,9 @@ package com.github.damontecres.wholphin.util import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback 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.extensions.playStateApi @@ -22,6 +20,7 @@ import timber.log.Timber import java.util.Timer import java.util.TimerTask import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -34,7 +33,7 @@ class TrackActivityPlaybackListener( private val player: Player, private val getState: () -> PlaybackItemState?, ) : Player.Listener { - private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) private val task: TimerTask = object : TimerTask() { override fun run() { @@ -57,7 +56,7 @@ class TrackActivityPlaybackListener( PlaybackStartInfo( canSeek = true, itemId = state.itemId, - isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying }, playMethod = state.playMethod, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, @@ -85,10 +84,13 @@ class TrackActivityPlaybackListener( launch("reportPlaybackStopped") { getState.invoke()?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") + if (position < Duration.ZERO) { + Timber.w("Negative position when reporting playback stopped: %s", position) + } api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( itemId = state.itemId, - positionTicks = position.inWholeTicks, + positionTicks = position.inWholeTicks.takeIf { it >= 0 }, failed = false, playSessionId = state.playSessionId, liveStreamId = state.liveStreamId, @@ -117,11 +119,11 @@ class TrackActivityPlaybackListener( launch("saveActivity") { getState.invoke()?.let { state -> val calcPosition = - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { (if (position >= 0) position else player.currentPosition) } if (calcPosition > 0) { - val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } + val isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying } Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition") api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( @@ -174,12 +176,11 @@ data class PlaybackItemState( ) { constructor( playback: CurrentPlayback, - itemPlayback: ItemPlayback, ) : this( - itemId = itemPlayback.itemId, + itemId = playback.item.id, playMethod = playback.playMethod, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + audioStreamIndex = playback.audioIndex.takeIf { it >= 0 }, + subtitleStreamIndex = playback.subtitleIndex.takeIf { it >= 0 }, playSessionId = playback.playSessionId, liveStreamId = playback.liveStreamId, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt index 86b0f47e..f3ac966d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt @@ -7,7 +7,6 @@ import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.ui.playback.idAsInt import timber.log.Timber import java.util.Locale @@ -159,7 +158,7 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> = val reason = TrackSupportReason.fromInt(it.getTrackSupport(i)) add( TrackSupport( - format.id + " (${format.idAsInt})", + format.id, type, reason, it.isSelected, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt b/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt new file mode 100644 index 00000000..2a466742 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt @@ -0,0 +1,20 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.MainCoroutineDispatcher + +/** + * Analogous to [kotlinx.coroutines.Dispatchers], but easier to swap for testing + */ +object WholphinDispatchers { + @Suppress("ktlint:standard:property-naming") + val Main: MainCoroutineDispatcher get() = kotlinx.coroutines.Dispatchers.Main + + @Suppress("ktlint:standard:property-naming") + var IO: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO + internal set + + @Suppress("ktlint:standard:property-naming") + var Default: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default + internal set +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt deleted file mode 100644 index fad62f2b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ /dev/null @@ -1,311 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -/* -This file is copied from https://github.com/mpv-android/mpv-android/blob/master/app/src/main/java/is/xyz/mpv/MPVLib.kt - -Copyright (c) 2016 Ilya Zhuravlev -Copyright (c) 2016 sfan5 <sfan5@live.de> - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, -including without limitation the rights to use, copy, modify, merge, publish, distribute, -sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or -substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT -NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -import android.content.Context -import android.graphics.Bitmap -import android.view.Surface -import java.util.concurrent.CopyOnWriteArrayList - -// Wrapper for native library - -@Suppress("unused") -object MPVLib { - init { - val libs = arrayOf("mpv", "player") - for (lib in libs) { - System.loadLibrary(lib) - } - } - - external fun create(appctx: Context) - - external fun init() - - external fun destroy() - - external fun attachSurface(surface: Surface) - - external fun detachSurface() - - external fun command(cmd: Array<out String>) - - external fun setOptionString( - name: String, - value: String, - ): Int - - external fun grabThumbnail(dimension: Int): Bitmap? - - external fun getPropertyInt(property: String): Int? - - external fun setPropertyInt( - property: String, - value: Int, - ) - - external fun getPropertyDouble(property: String): Double? - - external fun setPropertyDouble( - property: String, - value: Double, - ) - - external fun getPropertyBoolean(property: String): Boolean? - - external fun setPropertyBoolean( - property: String, - value: Boolean, - ) - - external fun getPropertyString(property: String): String? - - external fun setPropertyString( - property: String, - value: String, - ) - - external fun observeProperty( - property: String, - format: Int, - ) - - private val observers = CopyOnWriteArrayList<EventObserver>() - - @JvmStatic - fun addObserver(o: EventObserver) { - observers.add(o) - } - - @JvmStatic - fun removeObserver(o: EventObserver) { - observers.remove(o) - } - - @JvmStatic - fun eventProperty( - property: String, - value: Long, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: Boolean, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: Double, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty( - property: String, - value: String, - ) { - for (o in observers) { - o.eventProperty(property, value) - } - } - - @JvmStatic - fun eventProperty(property: String) { - for (o in observers) { - o.eventProperty(property) - } - } - - @JvmStatic - fun event(eventId: Int) { - for (o in observers) { - o.event(eventId) - } - } - - @JvmStatic - fun eventEndFile( - reason: Int, - error: Int, - ) { - for (o in observers) { - o.eventEndFile(reason, error) - } - } - - private val log_observers = CopyOnWriteArrayList<LogObserver>() - - @JvmStatic - fun addLogObserver(o: LogObserver) { - log_observers.add(o) - } - - @JvmStatic - fun removeLogObserver(o: LogObserver) { - log_observers.remove(o) - } - - @JvmStatic - fun logMessage( - prefix: String, - level: Int, - text: String, - ) { - for (o in log_observers) { - o.logMessage(prefix, level, text) - } - } - - interface EventObserver { - fun eventProperty(property: String) - - fun eventProperty( - property: String, - value: Long, - ) - - fun eventProperty( - property: String, - value: Boolean, - ) - - fun eventProperty( - property: String, - value: String, - ) - - fun eventProperty( - property: String, - value: Double, - ) - - fun event(eventId: Int) - - fun eventEndFile( - reason: Int, - error: Int, - ) - } - - interface LogObserver { - fun logMessage( - prefix: String, - level: Int, - text: String, - ) - } - - object MpvFormat { - const val MPV_FORMAT_NONE: Int = 0 - const val MPV_FORMAT_STRING: Int = 1 - const val MPV_FORMAT_OSD_STRING: Int = 2 - const val MPV_FORMAT_FLAG: Int = 3 - const val MPV_FORMAT_INT64: Int = 4 - const val MPV_FORMAT_DOUBLE: Int = 5 - const val MPV_FORMAT_NODE: Int = 6 - const val MPV_FORMAT_NODE_ARRAY: Int = 7 - const val MPV_FORMAT_NODE_MAP: Int = 8 - const val MPV_FORMAT_BYTE_ARRAY: Int = 9 - } - - object MpvEvent { - const val MPV_EVENT_NONE: Int = 0 - const val MPV_EVENT_SHUTDOWN: Int = 1 - const val MPV_EVENT_LOG_MESSAGE: Int = 2 - const val MPV_EVENT_GET_PROPERTY_REPLY: Int = 3 - const val MPV_EVENT_SET_PROPERTY_REPLY: Int = 4 - const val MPV_EVENT_COMMAND_REPLY: Int = 5 - const val MPV_EVENT_START_FILE: Int = 6 - const val MPV_EVENT_END_FILE: Int = 7 - const val MPV_EVENT_FILE_LOADED: Int = 8 - - @Deprecated("") - const val MPV_EVENT_IDLE: Int = 11 - - @Deprecated("") - const val MPV_EVENT_TICK: Int = 14 - const val MPV_EVENT_CLIENT_MESSAGE: Int = 16 - const val MPV_EVENT_VIDEO_RECONFIG: Int = 17 - const val MPV_EVENT_AUDIO_RECONFIG: Int = 18 - const val MPV_EVENT_SEEK: Int = 20 - const val MPV_EVENT_PLAYBACK_RESTART: Int = 21 - const val MPV_EVENT_PROPERTY_CHANGE: Int = 22 - const val MPV_EVENT_QUEUE_OVERFLOW: Int = 24 - const val MPV_EVENT_HOOK: Int = 25 - } - - object MpvLogLevel { - const val MPV_LOG_LEVEL_NONE: Int = 0 - const val MPV_LOG_LEVEL_FATAL: Int = 10 - const val MPV_LOG_LEVEL_ERROR: Int = 20 - const val MPV_LOG_LEVEL_WARN: Int = 30 - const val MPV_LOG_LEVEL_INFO: Int = 40 - const val MPV_LOG_LEVEL_V: Int = 50 - const val MPV_LOG_LEVEL_DEBUG: Int = 60 - const val MPV_LOG_LEVEL_TRACE: Int = 70 - } - - object MpvEndFileReason { - const val MPV_END_FILE_REASON_EOF: Int = 0 - const val MPV_END_FILE_REASON_STOP: Int = 2 - const val MPV_END_FILE_REASON_QUIT: Int = 3 - const val MPV_END_FILE_REASON_ERROR: Int = 4 - const val MPV_END_FILE_REASON_REDIRECT: Int = 5 - } - - object MpvError { - const val MPV_ERROR_SUCCESS = 0 - const val MPV_ERROR_EVENT_QUEUE_FULL = -1 - const val MPV_ERROR_NOMEM = -2 - const val MPV_ERROR_UNINITIALIZED = -3 - const val MPV_ERROR_INVALID_PARAMETER = -4 - const val MPV_ERROR_OPTION_NOT_FOUND = -5 - const val MPV_ERROR_OPTION_FORMAT = -6 - const val MPV_ERROR_OPTION_ERROR = -7 - const val MPV_ERROR_PROPERTY_NOT_FOUND = -8 - const val MPV_ERROR_PROPERTY_FORMAT = -9 - const val MPV_ERROR_PROPERTY_UNAVAILABLE = -10 - const val MPV_ERROR_PROPERTY_ERROR = -11 - const val MPV_ERROR_COMMAND = -12 - const val MPV_ERROR_LOADING_FAILED = -13 - const val MPV_ERROR_AO_INIT_FAILED = -14 - const val MPV_ERROR_VO_INIT_FAILED = -15 - const val MPV_ERROR_NOTHING_TO_PLAY = -16 - const val MPV_ERROR_UNKNOWN_FORMAT = -17 - const val MPV_ERROR_UNSUPPORTED = -18 - const val MPV_ERROR_NOT_IMPLEMENTED = -19 - const val MPV_ERROR_GENERIC = -20 - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt deleted file mode 100644 index f9a0669a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_DOUBLE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_FLAG -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_INT64 -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_NONE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvFormat.MPV_FORMAT_STRING - -object MPVProperty { - const val POSITION = "time-pos" - const val POSITION_FULL = "time-pos/full" - const val DURATION = "duration/full" - const val PAUSED = "pause" - const val PAUSED_FOR_CACHE = "paused-for-cache" - const val SPEED = "speed" - const val TRACK_LIST = "track-list" - const val ASPECT = "video-params/aspect" - const val ROTATE = "video-params/rotate" - const val TRACK_VIDEO = "current-tracks/video/image" - const val METADATA = "metadata" - const val HWDEC = "hwdec-current" - const val MUTE = "mute" - const val TRACK_AUDIO = "current-tracks/audio/selected" - const val SEEKABLE = "seekable" - const val SUBTITLE_TEXT = "sub-text" - - val observedProperties = - mapOf( - POSITION to MPV_FORMAT_INT64, -// POSITION_FULL to MPV_FORMAT_DOUBLE, - DURATION to MPV_FORMAT_DOUBLE, - PAUSED to MPV_FORMAT_FLAG, - PAUSED_FOR_CACHE to MPV_FORMAT_FLAG, - SPEED to MPV_FORMAT_STRING, - TRACK_LIST to MPV_FORMAT_NONE, - ASPECT to MPV_FORMAT_DOUBLE, - ROTATE to MPV_FORMAT_DOUBLE, - TRACK_VIDEO to MPV_FORMAT_NONE, - METADATA to MPV_FORMAT_NONE, - HWDEC to MPV_FORMAT_NONE, - MUTE to MPV_FORMAT_FLAG, - TRACK_AUDIO to MPV_FORMAT_NONE, - SEEKABLE to MPV_FORMAT_FLAG, - SUBTITLE_TEXT to MPV_FORMAT_STRING, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt deleted file mode 100644 index 300d36f0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvLogger.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import timber.log.Timber - -class MpvLogger : MPVLib.LogObserver { - override fun logMessage( - prefix: String, - level: Int, - text: String, - ) { - // https://github.com/mpv-player/mpv/blob/122abdfec3124bfc92a2918a70ca8150eee68338/include/mpv/client.h#L1423 - when { - level <= 10 -> Timber.wtf("%s, %s", prefix, text) - level <= 20 -> Timber.e("%s, %s", prefix, text) - level <= 30 -> Timber.w("%s, %s", prefix, text) - level <= 40 -> Timber.i("%s, %s", prefix, text) - level <= 50 -> Timber.d("%s, %s", prefix, text) - else -> Timber.v("%s, %s", prefix, text) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt deleted file mode 100644 index 1766de82..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ /dev/null @@ -1,1254 +0,0 @@ -package com.github.damontecres.wholphin.util.mpv - -import android.content.Context -import android.os.Build -import android.os.Handler -import android.os.HandlerThread -import android.os.Looper -import android.os.Message -import android.os.Process -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.annotation.OptIn -import androidx.compose.ui.graphics.Color -import androidx.media3.common.AudioAttributes -import androidx.media3.common.BasePlayer -import androidx.media3.common.C -import androidx.media3.common.DeviceInfo -import androidx.media3.common.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.MimeTypes -import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.TrackGroup -import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.Tracks -import androidx.media3.common.VideoSize -import androidx.media3.common.text.CueGroup -import androidx.media3.common.util.Clock -import androidx.media3.common.util.ListenerSet -import androidx.media3.common.util.Size -import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util -import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -import androidx.media3.exoplayer.trackselection.TrackSelector -import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_RECONFIG -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_START_FILE -import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG -import timber.log.Timber -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.concurrent.atomics.update -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -/** - * This is barebones implementation of a [Player] which plays content using libmpv - * - * It doesn't support every feature or emit every event - */ -@kotlin.OptIn(ExperimentalAtomicApi::class) -@OptIn(UnstableApi::class) -class MpvPlayer( - private val context: Context, - private val enableHardwareDecoding: Boolean, - private val useGpuNext: Boolean, -) : BasePlayer(), - MPVLib.EventObserver, - TrackSelector.InvalidationListener, - Handler.Callback, - SurfaceHolder.Callback { - companion object { - private const val DEBUG = false - - private val initLock = Any() - } - - private var surface: Surface? = null - private val playbackState = AtomicReference<PlaybackState>(PlaybackState.EMPTY) - private val mpvLogger = MpvLogger() - - // This looper will sent events to the main thread - private val looper = Util.getCurrentOrMainLooper() - private val mainHandler = Handler(looper) - private val listeners = - ListenerSet<Player.Listener>( - looper, - Clock.DEFAULT, - ) { listener, eventFlags -> - listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) - } - private var availableCommands: Player.Commands = Player.Commands.Builder().build() - private val trackSelector = DefaultTrackSelector(context) - - // This thread/looper will receive commands from the main thread to execute - private val thread: HandlerThread = - HandlerThread("MpvPlayer:Playback", Process.THREAD_PRIORITY_AUDIO) - .also { it.start() } - private val internalHandler: Handler = Handler(thread.looper, this) - - @Volatile - var isReleased = false - private set - - init { - sendCommand(MpvCommand.INITIALIZE, null) - } - - private fun init() { - Timber.v("config-dir=${context.filesDir.path}") - MPVLib.addLogObserver(mpvLogger) - - Timber.v("Creating MPVLib") - MPVLib.create(context) - MPVLib.setOptionString("config", "yes") - MPVLib.setOptionString("config-dir", context.filesDir.path) - - if (enableHardwareDecoding) { - MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy") - MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") - } else { - MPVLib.setOptionString("hwdec", "no") - } - MPVLib.setOptionString("gpu-context", "android") - - MPVLib.setOptionString("opengl-es", "yes") - MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") - val cacheMegs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) 64 else 32 - MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") - MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - - Timber.v("Initializing MPVLib") - MPVLib.init() - - MPVLib.setOptionString("force-window", "no") - MPVLib.setOptionString("idle", "yes") -// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) - - MPVLib.addObserver(this@MpvPlayer) - MPVProperty.observedProperties.forEach(MPVLib::observeProperty) - - availableCommands = - Player.Commands - .Builder() - .addAll( - COMMAND_PLAY_PAUSE, - COMMAND_PREPARE, - COMMAND_STOP, - COMMAND_SET_SPEED_AND_PITCH, - COMMAND_SET_SHUFFLE_MODE, - COMMAND_SET_REPEAT_MODE, - COMMAND_GET_CURRENT_MEDIA_ITEM, - COMMAND_GET_TIMELINE, - COMMAND_GET_METADATA, -// COMMAND_SET_PLAYLIST_METADATA, - COMMAND_SET_MEDIA_ITEM, -// COMMAND_CHANGE_MEDIA_ITEMS, - COMMAND_GET_TRACKS, -// COMMAND_GET_AUDIO_ATTRIBUTES, -// COMMAND_SET_AUDIO_ATTRIBUTES, -// COMMAND_GET_VOLUME, -// COMMAND_SET_VOLUME, - COMMAND_SET_VIDEO_SURFACE, -// COMMAND_GET_TEXT, - COMMAND_RELEASE, - ).build() - notifyListeners(EVENT_AVAILABLE_COMMANDS_CHANGED) { - onAvailableCommandsChanged( - availableCommands, - ) - } - trackSelector.init(this, DefaultBandwidthMeter.getSingletonInstance(context)) - } - - override fun getApplicationLooper(): Looper = looper - - override fun addListener(listener: Player.Listener) { - if (DEBUG) Timber.v("addListener") - listeners.add(listener) - } - - override fun removeListener(listener: Player.Listener) { - if (DEBUG) Timber.v("removeListener") - listeners.remove(listener) - } - - override fun setMediaItems( - mediaItems: List<MediaItem>, - resetPosition: Boolean, - ) { - if (DEBUG) Timber.v("setMediaItems") - throwIfReleased() - mediaItems.firstOrNull()?.let { - if (surface != null) { - sendCommand(MpvCommand.LOAD_FILE, MediaAndPosition(it, C.TIME_UNSET)) - } - } - } - - override fun setMediaItems( - mediaItems: List<MediaItem>, - startIndex: Int, - startPositionMs: Long, - ) { - if (DEBUG) Timber.v("setMediaItems") - throwIfReleased() - sendCommand( - MpvCommand.LOAD_FILE, - MediaAndPosition(mediaItems[startIndex], startPositionMs), - ) - } - - override fun addMediaItems( - index: Int, - mediaItems: List<MediaItem>, - ): Unit = throw UnsupportedOperationException() - - override fun moveMediaItems( - fromIndex: Int, - toIndex: Int, - newIndex: Int, - ): Unit = throw UnsupportedOperationException() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: List<MediaItem>, - ): Unit = throw UnsupportedOperationException() - - override fun removeMediaItems( - fromIndex: Int, - toIndex: Int, - ): Unit = throw UnsupportedOperationException() - - override fun getAvailableCommands(): Player.Commands = availableCommands - - override fun prepare() { - if (DEBUG) Timber.v("prepare") - } - - override fun getPlaybackState(): Int { - if (DEBUG) Timber.v("getPlaybackState") - return playbackState.load().state - } - - override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE - - override fun getPlayerError(): PlaybackException? { - // TODO - return null - } - - override fun setPlayWhenReady(playWhenReady: Boolean) { - if (isReleased) return - if (DEBUG) Timber.v("setPlayWhenReady: $playWhenReady") - sendCommand(MpvCommand.PLAY_PAUSE, playWhenReady) - } - - override fun getPlayWhenReady(): Boolean { - if (DEBUG) Timber.v("getPlayWhenReady") - if (isReleased) return false - return !playbackState.load().isPaused - } - - override fun setRepeatMode(repeatMode: Int) { - if (DEBUG) Timber.v("setRepeatMode") - } - - override fun getRepeatMode(): Int = REPEAT_MODE_OFF - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - if (DEBUG) Timber.v("setShuffleModeEnabled") - } - - override fun getShuffleModeEnabled(): Boolean = false - - override fun isLoading(): Boolean = playbackState.load().isLoadingFile - - override fun getSeekBackIncrement(): Long = 10_000 - - override fun getSeekForwardIncrement(): Long = 30_000 - - override fun getMaxSeekToPreviousPosition(): Long = 10_000 - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { - if (DEBUG) Timber.v("setPlaybackParameters") - sendCommand(MpvCommand.SET_SPEED, playbackParameters.speed) - } - - override fun getPlaybackParameters(): PlaybackParameters { - if (DEBUG) Timber.v("getPlaybackParameters") - return PlaybackParameters(playbackState.load().speed) - } - - override fun stop() { - if (DEBUG) Timber.v("stop") - if (isReleased) return - pause() - internalHandler.removeCallbacks(updatePlaybackState) - playbackState.update { - PlaybackState.EMPTY - } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) } - } - - override fun release() { - Timber.i("release") - playbackState.update { - PlaybackState.EMPTY - } - if (!isReleased) { - internalHandler.removeCallbacks(updatePlaybackState) - MPVLib.removeObserver(this@MpvPlayer) - sendCommand(MpvCommand.DESTROY, null) - thread.quitSafely() - } - isReleased = true - } - - override fun getCurrentTracks(): Tracks { - if (DEBUG) Timber.v("getCurrentTracks") - if (isReleased) return Tracks.EMPTY - return playbackState.load().tracks - } - - override fun getTrackSelectionParameters(): TrackSelectionParameters { - if (DEBUG) Timber.v("getTrackSelectionParameters") - - return TrackSelectionParameters - .Builder() - .build() - } - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { - Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters) - if (isReleased) return - val tracks = playbackState.load().tracks - if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) { - // Subtitles disabled - Timber.v("TrackSelection: disabling subtitles") - sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("sid", "no")) - } - if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) { - // Audio disabled - Timber.v("TrackSelection: disabling audio") - sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("aid", "no")) - } - Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides") - parameters.overrides.forEach { (trackGroup, trackSelectionOverride) -> - val result = - tracks.groups.firstOrNull { it.mediaTrackGroup == trackGroup }?.let { - val id = it.mediaTrackGroup.getFormat(0).id - val splits = id?.split(":") - val trackId = splits?.getOrNull(1) - val propertyName = - when (it.mediaTrackGroup.type) { - C.TRACK_TYPE_AUDIO -> "aid" - C.TRACK_TYPE_VIDEO -> "vid" - C.TRACK_TYPE_TEXT -> "sid" - else -> null - } - Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id) - if (trackId != null && propertyName != null) { - sendCommand( - MpvCommand.SET_TRACK_SELECTION, - TrackSelection(propertyName, trackId), - ) - true - } else { - false - } - } - if (result != true) { - Timber.w( - "Did not find track to select for type=%s, id=%s", - trackGroup.type, - trackGroup.getFormat(0).id, - ) - } - } - } - - override fun getMediaMetadata(): MediaMetadata { - if (DEBUG) Timber.v("getMediaMetadata") - return MediaMetadata.EMPTY - } - - override fun getPlaylistMetadata(): MediaMetadata { - if (DEBUG) Timber.v("getPlaylistMetadata") - return MediaMetadata.EMPTY - } - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata): Unit = throw UnsupportedOperationException() - - override fun getCurrentTimeline(): Timeline { - if (DEBUG) Timber.v("getCurrentTimeline") - return playbackState.load().timeline - } - - override fun getCurrentPeriodIndex(): Int { - if (DEBUG) Timber.v("getCurrentPeriodIndex") - // TODO - return 0 - } - - override fun getCurrentMediaItemIndex(): Int { - if (DEBUG) Timber.v("getCurrentMediaItemIndex") - return 0 - } - - override fun getDuration(): Long { - if (DEBUG) Timber.v("getDuration") - return playbackState.load().durationMs - } - - override fun getCurrentPosition(): Long { - if (DEBUG) Timber.v("getCurrentPosition") - val state = playbackState.load() - return state.positionMs - } - - override fun getBufferedPosition(): Long { - if (DEBUG) Timber.v("getBufferedPosition") - return currentPosition + totalBufferedDuration - } - - override fun getTotalBufferedDuration(): Long { - if (DEBUG) Timber.v("getTotalBufferedDuration") - if (isReleased) return 0 - return playbackState.load().bufferMs - } - - override fun isPlayingAd(): Boolean { - if (DEBUG) Timber.v("isPlayingAd") - return false - } - - override fun getCurrentAdGroupIndex(): Int = C.INDEX_UNSET - - override fun getCurrentAdIndexInAdGroup(): Int = C.INDEX_UNSET - - override fun getContentPosition(): Long = currentPosition - - override fun getContentBufferedPosition(): Long = bufferedPosition - - override fun getAudioAttributes(): AudioAttributes = throw UnsupportedOperationException() - - override fun setVolume(volume: Float): Unit = throw UnsupportedOperationException() - - override fun getVolume(): Float = 1f - - override fun mute() { - volume = 0f - } - - override fun unmute() { - volume = 1f - } - - override fun clearVideoSurface(): Unit = throw UnsupportedOperationException() - - override fun clearVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() - - override fun setVideoSurface(surface: Surface?): Unit = throw UnsupportedOperationException() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() - - private var surfaceHolder: SurfaceHolder? = null - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) { - if (DEBUG) Timber.v("setVideoSurfaceView") - if (surfaceView != null) { - this.surfaceHolder?.removeCallback(this) - this.surfaceHolder = surfaceView.holder - if (surfaceView.holder != null) { - val surface = surfaceView.holder?.surface - surfaceView.holder.addCallback(this) - Timber.v("Got surface holder: isValid=${surface?.isValid}") - if (surface != null && surface.isValid) { - Timber.v("Queued attach") - sendCommand(MpvCommand.ATTACH_SURFACE, surface) - return - } - } - } - clearVideoSurfaceView(null) - } - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - if (surface != null && surface == surfaceView?.holder?.surface) { - Timber.d("clearVideoSurfaceView") - sendCommand(MpvCommand.ATTACH_SURFACE, null) - } else { - Timber.w("clearVideoSurfaceView called with different surface: %s", surfaceView) - } - } - - override fun surfaceChanged( - holder: SurfaceHolder, - format: Int, - width: Int, - height: Int, - ) { - Timber.v("surfaceChanged: format=$format, width=$width, height=$height") - } - - override fun surfaceCreated(holder: SurfaceHolder) { - Timber.v("surfaceCreated") - sendCommand(MpvCommand.ATTACH_SURFACE, holder.surface) - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - Timber.v("surfaceDestroyed") - sendCommand(MpvCommand.ATTACH_SURFACE, null) - } - - override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() - - override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() - - override fun getVideoSize(): VideoSize { - if (DEBUG) Timber.v("getVideoSize") - if (isReleased) return VideoSize.UNKNOWN - return playbackState.load().videoSize - } - - override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN - - override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO - - override fun getDeviceInfo(): DeviceInfo { - if (DEBUG) Timber.v("getDeviceInfo") - return DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE).build() - } - - override fun getDeviceVolume(): Int = throw UnsupportedOperationException() - - override fun isDeviceMuted(): Boolean = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun setDeviceVolume(volume: Int): Unit = throw UnsupportedOperationException() - - override fun setDeviceVolume( - volume: Int, - flags: Int, - ): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun increaseDeviceVolume(): Unit = throw UnsupportedOperationException() - - override fun increaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun decreaseDeviceVolume(): Unit = throw UnsupportedOperationException() - - override fun decreaseDeviceVolume(flags: Int): Unit = throw UnsupportedOperationException() - - @Deprecated("Deprecated in Java") - override fun setDeviceMuted(muted: Boolean): Unit = throw UnsupportedOperationException() - - override fun setDeviceMuted( - muted: Boolean, - flags: Int, - ): Unit = throw UnsupportedOperationException() - - override fun setAudioAttributes( - audioAttributes: AudioAttributes, - handleAudioFocus: Boolean, - ): Unit = throw UnsupportedOperationException() - - override fun seekTo( - mediaItemIndex: Int, - positionMs: Long, - seekCommand: Int, - isRepeatingCurrentItem: Boolean, - ) { - if (DEBUG) Timber.v("seekTo") - if (isReleased) { - Timber.w("seekTo called after release") - return - } - if (mediaItemIndex == C.INDEX_UNSET) { - return - } - sendCommand(MpvCommand.SEEK, positionMs) - } - - override fun onTrackSelectionsInvalidated() { - // no-op - } - - override fun eventProperty(property: String) { - if (DEBUG) Timber.v("eventProperty: $property") - } - - override fun eventProperty( - property: String, - value: Long, - ) { - if (DEBUG) Timber.v("eventPropertyLong: $property=$value") - when (property) { - MPVProperty.POSITION -> { - playbackState.update { - it.copy(positionMs = value.seconds.inWholeMilliseconds) - } - } - } - } - - override fun eventProperty( - property: String, - value: Boolean, - ) { - if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value") - when (property) { - MPVProperty.PAUSED -> { - playbackState.update { - it.copy(isPaused = value) - } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } - } - - MPVProperty.PAUSED_FOR_CACHE -> { - Timber.v("paused-for-cache %s", value) - playbackState.update { - it.copy( - state = if (value) STATE_BUFFERING else it.state, - ) - } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(playbackState.load().state) } - } - } - } - - override fun eventProperty( - property: String, - value: String, - ) { - if (DEBUG) Timber.v("eventPropertyString: $property=$value") - } - - override fun eventProperty( - property: String, - value: Double, - ) { - Timber.v("eventPropertyDouble: $property=$value") - when (property) { - MPVProperty.DURATION -> { - playbackState.update { - it.copy(durationMs = value.seconds.inWholeMilliseconds) - } - } - } - } - - override fun event(eventId: Int) { - Timber.v("event: eventId=%s", eventId) - when (eventId) { - MPV_EVENT_START_FILE -> { - internalHandler.post(updatePlaybackState) - } - - MPV_EVENT_FILE_LOADED -> { - Timber.d("event: MPV_EVENT_FILE_LOADED") - playbackState.update { - it.copy( - isLoadingFile = false, - ) - } - updatePlaybackState.run() - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } - - playbackState.load().media?.mediaItem?.let { media -> - media.localConfiguration?.subtitleConfigurations?.forEach { - val url = it.uri.toString() - val title = it.label ?: "External Subtitles" - Timber.v("Adding external subtitle track '$title'") - if (it.language.isNotNullOrBlank()) { - MPVLib.command(arrayOf("sub-add", url, "select", title, it.language!!)) - } else { - MPVLib.command(arrayOf("sub-add", url, "select", title)) - } - } - } - notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() } - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) } - updateTracksAndNotify() - } - - MPV_EVENT_PLAYBACK_RESTART -> { - Timber.d("event: MPV_EVENT_PLAYBACK_RESTART") - updateTracksAndNotify() - } - - MPV_EVENT_AUDIO_RECONFIG -> { - Timber.d("event: MPV_EVENT_AUDIO_RECONFIG") - updateTracksAndNotify() - } - - MPV_EVENT_VIDEO_RECONFIG -> { - Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") - updateTracksAndNotify() - updateVideoSizeAndNotify() - } - - MPV_EVENT_END_FILE -> { - Timber.d("event: MPV_EVENT_END_FILE") - // Handled by eventEndFile - } - - else -> { - Timber.v("event: $eventId") - } - } - } - - override fun eventEndFile( - reason: Int, - error: Int, - ) { - Timber.d("MPV_EVENT_END_FILE: %s %s", reason, error) - notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } - when (reason) { - MPV_END_FILE_REASON_EOF -> { - playbackState.update { - it.copy(state = Player.STATE_ENDED) - } - notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { - onPlaybackStateChanged(STATE_ENDED) - } - } - - MPV_END_FILE_REASON_STOP -> { - // User initiated (eg stop, play next, etc) - } - - MPV_END_FILE_REASON_ERROR -> { - Timber.e("libmpv error, error=%s", error) - notifyListeners(EVENT_PLAYER_ERROR) { - onPlayerError( - PlaybackException( - "libmpv error", - null, - error, - ), - ) - } - } - - else -> { - // no-op - } - } - } - - private fun updateTracksAndNotify() { - val tracks = createTracks() - playbackState.update { - it.copy(tracks = tracks) - } - notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) } - } - - private fun updateVideoSizeAndNotify() { - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - val videoSize = - if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } - playbackState.update { it.copy(videoSize = videoSize) } - notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { onVideoSizeChanged(videoSize) } - } - - private fun loadFile(media: MediaAndPosition) { - Timber.v("loadFile: media=$media") - val timeline = - object : Timeline() { - override fun getWindowCount(): Int = 1 - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long, - ): Window = - window.set( - media.mediaItem.mediaId, - media.mediaItem, - null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, - true, - true, - media.mediaItem.liveConfiguration, - 0L, - C.TIME_UNSET, - 0, - 0, - 0, - ) - - override fun getPeriodCount(): Int = 1 - - override fun getPeriod( - periodIndex: Int, - period: Period, - setIds: Boolean, - ): Period = - period.set( - media.mediaItem.mediaId, - media.mediaItem.mediaId, - 0, - C.TIME_UNSET, - 0, - ) - - override fun getIndexOfPeriod(uid: Any): Int = 0 - - override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId - } - playbackState.update { - it.copy( - isLoadingFile = true, - state = STATE_READY, - media = media, - timeline = timeline, - ) - } - notifyListeners(EVENT_TIMELINE_CHANGED) { - onTimelineChanged( - timeline, - TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - ) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } - val url = - media.mediaItem.localConfiguration - ?.uri - .toString() - if (media.startPositionMs > 0) { - MPVLib.command( - arrayOf( - "loadfile", - url, - "replace", - "-1", - "start=${media.startPositionMs / 1000.0}", - ), - ) - } else { - MPVLib.command(arrayOf("loadfile", url, "replace", "-1")) - } - - if (enableHardwareDecoding) { - MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu") - } - Timber.d("Called loadfile") - } - - private fun throwIfReleased() { - if (isReleased) { - throw IllegalStateException("Cannot access MpvPlayer after it is released") - } - } - - private fun notifyListeners( - eventId: Int, - block: Player.Listener.() -> Unit, - ) { - mainHandler.post { - listeners.queueEvent(eventId) { - block.invoke(it) - } - listeners.flushEvents() - } - } - - var subtitleDelaySeconds: Double - get() { - if (isReleased) return 0.0 - return playbackState.load().subtitleDelay - } - set(value) { - if (isReleased) return - sendCommand(MpvCommand.SET_SUBTITLE_DELAY, value) - } - - var subtitleDelay: Duration - get() { - if (isReleased) return Duration.ZERO - return subtitleDelaySeconds.seconds - } - set(value) { - if (isReleased) return - subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0 - } - - private val updatePlaybackState: Runnable = - Runnable { - val state = playbackState.load() - if (state.media == null) { - return@Runnable - } - val positionMs = - MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val bufferMs = - MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val durationMs = - MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds - ?: C.TIME_UNSET - val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f - val paused = MPVLib.getPropertyBoolean("pause") ?: false - val width = MPVLib.getPropertyInt("width") - val height = MPVLib.getPropertyInt("height") - val videoSize = - if (width != null && height != null) { - VideoSize(width, height) - } else { - VideoSize.UNKNOWN - } - - val mediaItem = state.media.mediaItem - val timeline = - object : Timeline() { - override fun getWindowCount(): Int = 1 - - override fun getWindow( - windowIndex: Int, - window: Window, - defaultPositionProjectionUs: Long, - ): Window = - window.set( - mediaItem.mediaId, - mediaItem, - null, - C.TIME_UNSET, - C.TIME_UNSET, - C.TIME_UNSET, - true, - false, - mediaItem.liveConfiguration, - 0L, - if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, - 0, - 0, - 0, - ) - - override fun getPeriodCount(): Int = 1 - - override fun getPeriod( - periodIndex: Int, - period: Period, - setIds: Boolean, - ): Period = - period.set( - mediaItem.mediaId, - mediaItem.mediaId, - 0, - state.durationMs.milliseconds.inWholeMicroseconds, - 0, - ) - - override fun getIndexOfPeriod(uid: Any): Int = 0 - - override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId - } - - playbackState.update { - it.copy( - timestamp = System.currentTimeMillis(), - positionMs = positionMs, - bufferMs = bufferMs, - durationMs = durationMs, - speed = speed, - isPaused = paused, - videoSize = videoSize, - timeline = timeline, - ) - } - notifyListeners(EVENT_TIMELINE_CHANGED) { - onTimelineChanged( - timeline, - TIMELINE_CHANGE_REASON_SOURCE_UPDATE, - ) - } - } - - private fun sendCommand( - cmd: MpvCommand, - obj: Any?, - ) { - internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget() - } - - private val queuedCommands = mutableListOf<Pair<MpvCommand, Any?>>() - - override fun handleMessage(msg: Message): Boolean { - val cmd = MpvCommand.entries[msg.what] - if (isReleased && cmd != MpvCommand.DESTROY) { - Timber.w("Player is released, ignoring command %s", cmd) - return true - } - if (surface == null && !cmd.isLifecycle) { - // If libmpv isn't ready, ueue the messages - // Note: this means nothing will play until it is attached to a surface, - // so MpvPlayer can't be used for background audio/music playback - Timber.v("MPV is not initialized/attached yet, queue cmd %s", cmd) - queuedCommands.add(Pair(cmd, msg.obj)) - } else { - handleCommand(cmd, msg.obj) - } - return true - } - - private fun handleCommand( - cmd: MpvCommand, - obj: Any?, - ) { - Timber.d("handleCommand: cmd=$cmd") - when (cmd) { - MpvCommand.PLAY_PAUSE -> { - val playWhenReady = obj as Boolean - MPVLib.setPropertyBoolean("pause", !playWhenReady) - playbackState.update { - it.copy(isPaused = !playWhenReady) - } - notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { - onPlayWhenReadyChanged( - playWhenReady, - PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - ) - } - } - - MpvCommand.SET_TRACK_SELECTION -> { - val (propertyName, trackId) = obj as TrackSelection - MPVLib.setPropertyString(propertyName, trackId) - updateTracksAndNotify() - } - - MpvCommand.SEEK -> { - val positionMs = obj as Long - MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) - playbackState.update { - it.copy(positionMs = positionMs) - } - } - - MpvCommand.SET_SPEED -> { - val value = obj as Float - MPVLib.setPropertyDouble("speed", value.toDouble()) - playbackState.update { - it.copy(speed = value) - } - } - - MpvCommand.SET_SUBTITLE_DELAY -> { - val value = obj as Double - MPVLib.setPropertyDouble("sub-delay", value) - playbackState.update { - it.copy(subtitleDelay = value) - } - } - - MpvCommand.LOAD_FILE -> { - loadFile(obj as MediaAndPosition) - } - - MpvCommand.ATTACH_SURFACE -> { - val surface = obj as Surface? - if (surface == null || (this.surface != null && this.surface != surface)) { - // If clearing or changing the surface - MPVLib.detachSurface() - MPVLib.setPropertyString("vo", "null") - MPVLib.setPropertyString("force-window", "no") - Timber.d("Detached surface") - } - if (surface != null) { - MPVLib.attachSurface(surface) - this.surface = surface - MPVLib.setOptionString("force-window", "yes") - Timber.d("Attached surface") - if (queuedCommands.isNotEmpty()) { - Timber.d("Processing queued commands") - while (queuedCommands.isNotEmpty()) { - val msg = queuedCommands.removeAt(0) - handleCommand(msg.first, msg.second) - } - } - } - } - - MpvCommand.INITIALIZE -> { - synchronized(initLock) { - init() - } - } - - MpvCommand.DESTROY -> { - synchronized(initLock) { - MPVLib.setPropertyBoolean("pause", true) - MPVLib.removeLogObserver(mpvLogger) - MPVLib.destroy() - Timber.d("MPVLib destroyed") - } - } - } - } -} - -fun MPVLib.setPropertyColor( - property: String, - color: Color, -) = setPropertyString(property, color.mpvFormat) - -private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" - -@OptIn(UnstableApi::class) -private fun createTracks(): Tracks { - val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY - val groups = - (0..<trackCount).mapNotNull { idx -> - val type = MPVLib.getPropertyString("track-list/$idx/type") - val id = MPVLib.getPropertyInt("track-list/$idx/id") - val lang = MPVLib.getPropertyString("track-list/$idx/lang") - val codec = MPVLib.getPropertyString("track-list/$idx/codec") - val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc") - val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false - val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false - val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false - val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false - val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count") - val title = MPVLib.getPropertyString("track-list/$idx/title") - - if (type != null && id != null) { - // TODO do we need the real mimetypes? - val mimeType = - when (type) { - "video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo" - "audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo" - "sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo" - else -> "unknown/todo" - } - var flags = 0 - if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT - if (isForced) flags = flags or C.SELECTION_FLAG_FORCED - val builder = - Format - .Builder() - .apply { - if (isExternal) { - setId("$idx:e:$id") - } else { - setId("$idx:$id") - } - }.setCodecs(codec) - .setSampleMimeType(mimeType) - .setLanguage(lang) - .setLabel(listOfNotNull(title, codecDescription).joinToString(",")) - .setSelectionFlags(flags) - if (type == "video" && isSelected) { - builder.setWidth(MPVLib.getPropertyInt("width") ?: -1) - builder.setHeight(MPVLib.getPropertyInt("height") ?: -1) - } - channelCount?.let(builder::setChannelCount) - val format = builder.build() -// Timber.v("$idx=$format") - - val trackGroup = TrackGroup(format) - val group = - Tracks.Group( - trackGroup, - false, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(isSelected), - ) - group - } else { - null - } - } - return Tracks(groups) -} - -private data class PlaybackState( - val timestamp: Long, - val isLoadingFile: Boolean, - val media: MediaAndPosition?, - val positionMs: Long, - val bufferMs: Long, - val durationMs: Long, - val isPaused: Boolean, - val speed: Float, - val subtitleDelay: Double, - val videoSize: VideoSize, - @param:Player.State val state: Int, - val tracks: Tracks, - val timeline: Timeline, -) { - companion object { - val EMPTY = - PlaybackState( - timestamp = C.TIME_UNSET, - isLoadingFile = false, - media = null, - positionMs = C.TIME_UNSET, - durationMs = C.TIME_UNSET, - tracks = Tracks.EMPTY, - bufferMs = C.TIME_UNSET, - isPaused = false, - speed = 1f, - videoSize = VideoSize.UNKNOWN, - state = Player.STATE_IDLE, - subtitleDelay = 0.0, - timeline = Timeline.EMPTY, - ) - } -} - -private data class TrackSelection( - val property: String, - val trackId: String, -) - -private data class MediaAndPosition( - val mediaItem: MediaItem, - val startPositionMs: Long, -) - -enum class MpvCommand( - val isLifecycle: Boolean, -) { - PLAY_PAUSE(false), - SEEK(false), - SET_TRACK_SELECTION(false), - SET_SPEED(false), - SET_SUBTITLE_DELAY(false), - LOAD_FILE(false), - ATTACH_SURFACE(true), - INITIALIZE(true), - DESTROY(true), -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt new file mode 100644 index 00000000..18e67f63 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt @@ -0,0 +1,11 @@ +package com.github.damontecres.wholphin.util.mpv + +import androidx.compose.ui.graphics.Color +import com.github.damontecres.wholphin.mpv.MPVLib + +fun MPVLib.setPropertyColor( + property: String, + color: Color, +) = setPropertyString(property, color.mpvFormat) + +private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" 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 2de5310e..d2efeb30 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 @@ -71,6 +71,7 @@ fun createDeviceProfile( dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, + preferAc3ForSurround: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -132,31 +133,61 @@ fun createDeviceProfile( // / Transcoding profiles // Video - transcodingProfile { - type = DlnaProfileType.VIDEO - context = EncodingContext.STREAMING + if (preferAc3ForSurround) { + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS - if (supportsHevc) videoCodec(Codec.Video.HEVC) - videoCodec(Codec.Video.H264) + if (supportsHevc) videoCodec(Codec.Video.HEVC) + videoCodec(Codec.Video.H264) - audioCodec(*allowedAudioCodecs) + audioCodec(Codec.Audio.AC3) - copyTimestamps = false - enableSubtitlesInManifest = true + copyTimestamps = false + enableSubtitlesInManifest = true + } + } else { + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING + + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + + if (supportsHevc) videoCodec(Codec.Video.HEVC) + videoCodec(Codec.Video.H264) + + audioCodec(*allowedAudioCodecs) + + copyTimestamps = false + enableSubtitlesInManifest = true + } } // Audio - transcodingProfile { - type = DlnaProfileType.AUDIO - context = EncodingContext.STREAMING + if (preferAc3ForSurround) { + transcodingProfile { + type = DlnaProfileType.AUDIO + context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.AAC) + audioCodec(Codec.Audio.AC3) + } + } else { + transcodingProfile { + type = DlnaProfileType.AUDIO + context = EncodingContext.STREAMING + + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + + audioCodec(Codec.Audio.AAC) + } } // / Direct play profiles @@ -203,6 +234,19 @@ fun createDeviceProfile( } // / Codec profiles + // Prevent 8-channel EAC3 passthrough in TS on certain devices + if (KnownDefects.eac3HlsPassthroughBug && Codec.Audio.EAC3 in allowedAudioCodecs) { + codecProfile { + type = CodecType.VIDEO_AUDIO + container = Codec.Container.TS + codec = Codec.Audio.EAC3 + + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 7 + } + } + } + // H264 profile codecProfile { type = CodecType.VIDEO @@ -517,6 +561,27 @@ fun createDeviceProfile( } } + if (preferAc3ForSurround) { + supportedAudioCodecs + .filterNot { it == Codec.Audio.AC3 } + .forEach { audioCodec -> + codecProfile { + type = CodecType.VIDEO_AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + codecProfile { + type = CodecType.AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + } + } + // Audio channel profile codecProfile { type = CodecType.VIDEO_AUDIO 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 a4cd60c4..d2a3c042 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 @@ -14,6 +14,14 @@ private val modelsWithDoViHdr10PlusBug = "AFTKM", // Amazon Fire TV 4K (2nd Gen) ) +/** + * List of device models with known playback issues when 8-channel EAC3 is copied into MPEG-TS. + */ +private val modelsWithEac3TsPassthroughBug = + listOf( + "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) + ) + /** * List of device models that support H264 Hi10P 5.2, but don't advertise it * @@ -30,5 +38,6 @@ private val modelsWithHi10P52Support = object KnownDefects { val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug + val eac3HlsPassthroughBug = Build.MODEL in modelsWithEac3TsPassthroughBug val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index bf98744a..98698f7c 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -87,6 +87,13 @@ message PlaybackPreferences { bool resolution_switching = 23; string external_player = 24; bool cinema_mode = 25; + DpadSeekMode dpad_seek_mode = 26; +} + +enum DpadSeekMode { + SEEKBAR_MINIMAL = 0; + SEEKBAR_TRICKPLAY = 1; + SEEKBAR_NONE = 2; } message HomePagePreferences{ @@ -113,6 +120,8 @@ enum AppThemeColors { ORANGE = 3; BOLD_BLUE = 4; OLED_BLACK = 5; + RED = 6; + BROWN = 7; } enum EdgeStyle{ @@ -141,6 +150,7 @@ message SubtitlePreferences{ int32 margin = 11; int32 edge_thickness = 12; int32 image_subtitle_opacity = 13; + bool use_separate_hdr = 14; } message LiveTvPreferences { @@ -164,6 +174,8 @@ message ScreensaverPreferences{ bool enabled = 5; repeated string item_types = 6; bool show_clock = 7; + bool dim_enabled = 8; + int32 dim_percent = 9; } message SearchPreferences { @@ -180,7 +192,7 @@ enum DisplayToggle{ message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; - map<string, int32> remembered_tabs = 3; + map<string, int32> remembered_tabs = 3 [deprecated = true]; AppThemeColors app_theme_colors = 4; bool nav_drawer_switch_on_focus = 5; bool show_clock = 6; @@ -211,6 +223,13 @@ message MusicPreferences { bool show_backdrop = 4; } +message ExperimentalPreferences { + bool enabled = 1; + bool video_tunneling_enabled = 2; + bool prefer_ac3_surround = 3; + bool disable_audio_offload = 4; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -228,4 +247,5 @@ message AppPreferences { bool sign_in_automatically = 11; PhotoPreferences photo_preferences = 12; MusicPreferences music_preferences = 13; + ExperimentalPreferences experimental_preferences = 14; } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0bfa6343..6db20221 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -31,7 +31,7 @@ <string name="director">المخرج</string> <string name="disabled">مُعطّل</string> <string name="discovered_servers">الخوادم المكتشفة</string> - <string name="download_and_update"><![CDATA[تنزيل وتحديث]]></string> + <string name="download_and_update">تنزيل وتحديث</string> <string name="downloading">تنزيل…</string> <string name="enabled">مُفعّل</string> <string name="ends_at">ينتهي عند %1$s</string> @@ -66,11 +66,7 @@ <string name="more">المزيد</string> <plurals name="movies"> <item quantity="zero">الأفلام</item> - <item quantity="one"/> - <item quantity="two"/> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">الأفلام</item> </plurals> <string name="name">الاسم</string> <string name="next_up">التالي</string> @@ -85,11 +81,7 @@ <string name="path">المسار</string> <plurals name="people"> <item quantity="zero">الأشخاص</item> - <item quantity="one"/> - <item quantity="two"/> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">الأشخاص</item> </plurals> <string name="play_count">عدد مرات التشغيل</string> <string name="play_from_here">تشغيل من هنا</string> @@ -114,7 +106,7 @@ <string name="restart">إعادة تشغيل</string> <string name="resume">استئناف</string> <string name="save">حفظ</string> - <string name="search_and_download"><![CDATA[بحث وتنزيل]]></string> + <string name="search_and_download">بحث وتنزيل</string> <string name="search">بحث</string> <string name="searching">جارٍ البحث…</string> <string name="voice_search">البحث الصوتي</string> @@ -161,32 +153,41 @@ <string name="show_clock">إظهار الساعة</string> <plurals name="behind_the_scenes"> <item quantity="zero">الكواليس</item> + <item quantity="other">الكواليس</item> </plurals> <string name="official_rating">التصنيف العمري</string> <string name="runtime_sort">مدة العرض</string> - <string name="extras">إضافات</string> + <string name="extras_title">إضافات</string> <plurals name="other_extras"> <item quantity="zero">أخرى</item> + <item quantity="other">أخرى</item> </plurals> <plurals name="theme_songs"> <item quantity="zero">الأغاني الرئيسية</item> + <item quantity="other">الأغاني الرئيسية</item> </plurals> <plurals name="clips"> <item quantity="zero">مقاطع</item> + <item quantity="other">مقاطع</item> </plurals> <plurals name="deleted_scenes"> <item quantity="zero">مشاهد محذوفة</item> + <item quantity="other">مشاهد محذوفة</item> </plurals> <plurals name="interviews"> <item quantity="zero">مقابلات</item> + <item quantity="other">مقابلات</item> </plurals> <plurals name="scenes"> <item quantity="zero">مشاهد</item> + <item quantity="other">مشاهد</item> </plurals> <plurals name="featurettes"> <item quantity="zero">مقاطع خاصة</item> + <item quantity="other">مقاطع خاصة</item> </plurals> <plurals name="shorts"> <item quantity="zero">مقاطع قصيرة</item> + <item quantity="other">مقاطع قصيرة</item> </plurals> </resources> diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 045e125f..d7509408 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -1,3 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <string name="about">Аб праграме</string> + <string name="add_server">Дадаць сервер</string> + <string name="add_user">Двдаць карыстальніка</string> </resources> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e41bd2b5..7de6e9ac 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -63,9 +63,7 @@ <string name="more">Více</string> <plurals name="movies"> <item quantity="one">Filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Filmy</item> </plurals> <string name="name">Jméno</string> <string name="next_up">Další v pořadí</string> @@ -80,9 +78,7 @@ <string name="path">Cesta</string> <plurals name="people"> <item quantity="one">Lidé</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Lidé</item> </plurals> <string name="play_count">Počet přehrání</string> <string name="play_from_here">Přehrát odtud</string> @@ -149,12 +145,9 @@ <string name="switch_servers">Přepnout servery</string> <string name="switch_user">Přepnout</string> <string name="top_unwatched">Nejlépe hodnocené Nezhlédnuté</string> - <string name="trailer">Upoutávka</string> <plurals name="trailers"> <item quantity="one">Upoutávky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Upoutávky</item> </plurals> <string name="tv_dvr_schedule">Harmonogram nahrávání</string> <string name="tv_guide">Televizní program</string> @@ -162,9 +155,7 @@ <string name="tv_seasons">Sezóny</string> <plurals name="tv_shows"> <item quantity="one">Seriály</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Seriály</item> </plurals> <string name="ui_interface">Prostředí</string> <string name="unknown">Neznámý</string> @@ -174,7 +165,9 @@ <string name="video">Video</string> <string name="videos">Videa</string> <string name="watch_live">Sledujte živě</string> - <string name="years_old">%1$d let starý</string> + <plurals name="years_old"> + <item quantity="other">%1$d let starý</item> + </plurals> <string name="play_with_transcoding">Přehraj s překódováním</string> <string name="media_information">Informace o médiu</string> <string name="show_clock">Zobrazit hodiny</string> @@ -188,60 +181,42 @@ <string name="background">Pozadí</string> <string name="success">Úspěch</string> <string name="official_rating">Rodičovské hodnocení</string> - <string name="extras">Doplňky</string> + <string name="extras_title">Doplňky</string> <plurals name="other_extras"> <item quantity="one">Jiný</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Jiný</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">V zákulisí</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">V zákulisí</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Znělky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Znělky</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Tématická videa</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Tématická videa</item> </plurals> <plurals name="clips"> <item quantity="one">Klipy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Klipy</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Vymazané scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Vymazané scény</item> </plurals> <plurals name="interviews"> <item quantity="one">Rozhovory</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Rozhovory</item> </plurals> <plurals name="scenes"> <item quantity="one">Scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Scény</item> </plurals> <plurals name="featurettes"> <item quantity="one">Krátkometrážní filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Krátkometrážní filmy</item> </plurals> <plurals name="hours"> <item quantity="one">%d hodina</item> @@ -423,21 +398,17 @@ <string name="request_4k">Žádost ve 4K</string> <string name="software_decoding_av1">Softwarové dekódování AV1</string> <string name="commercial">Komerční</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update">Download & Update</string> <string name="immediate">Bezprostředně</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download"><![CDATA[Najít & Stáhnout]]></string> <string name="runtime_sort">Trvání</string> <plurals name="samples"> <item quantity="one">Vzorky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Vzorky</item> </plurals> <plurals name="shorts"> <item quantity="one">Krátká videa</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Krátká videa</item> </plurals> <plurals name="downloads"> <item quantity="one">%s Stažen</item> @@ -445,11 +416,11 @@ <item quantity="many">%s Stažení</item> <item quantity="other">%s Stažení</item> </plurals> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Direct play ASS titulky</string> + <string name="combine_continue_next">Combine Continue Watching & Next Up</string> + <string name="direct_play_ass">Použít libass pro ASS titulky</string> <string name="direct_play_pgs">Direct play PGS titulky</string> <string name="player_backend">Přehrávání Backendu</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew">Cast & Crew</string> <string name="anamorphic">Anamorfní</string> <string name="interlaced">Prokládané</string> <string name="color_code_programs">Color code programs</string> @@ -512,7 +483,7 @@ <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="favorite_items_title">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> @@ -559,12 +530,57 @@ <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="enter_url_api_key">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="search_and_download_subtitles">Hledat a stáhnout titulky</string> <string name="no_subtitles_found">Nenalezeny žádné vzdálené titulky</string> <string name="series_continueing">Současnost</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="movies_title">Filmy</string> + <string name="people_title">Osoby</string> + <string name="profile_specific_settings_from_server">Nastavení uživatelského profilu ze serveru</string> + <string name="time_left">%1$s zbývá</string> + <string name="trailers_title">Ukázky</string> + <string name="tv_shows_title">TV Seriály</string> + <string name="other_extras_title">Jiné</string> + <string name="behind_the_scenes_title">Ze zákulisí</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Vystřižené scény</string> + <string name="interviews_title">Rozhovory</string> + <string name="scenes_title">Scény</string> + <string name="samples_title">Ukázky</string> + <string name="featurettes_title">Film o filmu</string> + <string name="shorts_title">Krátké filmy</string> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minut</item> + <item quantity="many">%s minut</item> + <item quantity="other">%s minut</item> + </plurals> + <string name="require_login">Požaduje přihlášení pro profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Vypnout hardwarové zpracování zvuku</string> + <string name="disable_audio_offload_summary">Vypnutí hardwarového dekódování zvuku může u některých zařízení vyřešit zamrzání přehrávání</string> + <string name="combined_search_results">Společné výsledky vyhledávání</string> + <string name="combined_search_results_on">Zobraz všechny výsledky v jednom seznamu</string> + <string name="combined_search_results_off">Zobraz výsledky podle kategorie</string> + <string name="show_voice_search_button">Zobraz tlačítko pro hlasové vyhledávání</string> + <string name="visible_ui">Viditelné</string> + <string name="hidden_ui">Skryté</string> + <string name="results">Výsledky</string> + <string name="albums">Alba</string> + <string name="artists">Tvůrci</string> + <string name="songs">Skladby</string> + <string name="go_to_artist">Přejdi na tvůrce</string> + <string name="now_playing">Právě hraje</string> + <string name="go_to_album">Jdi do alba</string> + <string name="add_to_queue">Přidat do fronty</string> </resources> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7fdb5d82..870d8c85 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -30,7 +30,7 @@ <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="download_and_update">Download og opdater</string> <string name="downloading">Downloader…</string> <string name="enabled">Aktiveret</string> <string name="ends_at">Slutter %1$s</string> @@ -45,19 +45,19 @@ <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_controller_timeout">Skjul afspilningskontroller</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="immediate">Med det samme</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_entire_series_as_played">Markér hele serien som set?</string> + <string name="mark_entire_series_as_unplayed">Markér hele serien som ikke set?</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> @@ -65,7 +65,7 @@ <string name="more">Mere</string> <plurals name="movies"> <item quantity="one">Film</item> - <item quantity="other"/> + <item quantity="other">Film</item> </plurals> <string name="name">Navn</string> <string name="next_up">Næste</string> @@ -76,20 +76,20 @@ <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="outro">Outro</string> <string name="path">Filsti</string> <plurals name="people"> - <item quantity="one">Personer</item> - <item quantity="other"/> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> </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_debug_info">Vis fejlfindingsoplysninger 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="playlist">Playliste</string> + <string name="playlists">Playlister</string> <string name="preview">Forhåndsvisning</string> <string name="profile_specific_settings">Profilindstillinger</string> <string name="queue">Kø</string> @@ -97,7 +97,7 @@ <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="recently_released">Nyligt udgivet</string> <string name="recommended">Anbefalet</string> <string name="record_program">Optag program</string> <string name="record_series">Optag serie</string> @@ -105,13 +105,13 @@ <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_and_download">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="processing">Behandler</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> @@ -119,7 +119,7 @@ <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_busy">Stemmegenkendelsen er allerede i brug</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> @@ -129,12 +129,12 @@ <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_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_episode_added">Dato episoden blev 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> @@ -145,38 +145,40 @@ <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_servers">Skift server</string> <string name="switch_user">Skift</string> <string name="top_unwatched">Bedst bedømte usete</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailere</item> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">TV-guide</string> <string name="tv_season">Sæson</string> <string name="tv_seasons">Sæsoner</string> <plurals name="tv_shows"> - <item quantity="one">TV-serier</item> - <item quantity="other"/> + <item quantity="one">TV-serie</item> + <item quantity="other">TV-serier</item> </plurals> - <string name="ui_interface">Grænseflade</string> + <string name="ui_interface">Brugerflade</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="watch_live">Se live</string> + <plurals name="years_old"> + <item quantity="one">%1$d år gammel</item> + <item quantity="other">%1$d år gammel</item> + </plurals> <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">Sæt på 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> @@ -184,50 +186,50 @@ <string name="success">Succes</string> <string name="official_rating">Aldersgrænse</string> <string name="runtime_sort">Spilletid</string> - <string name="extras">Ekstra</string> + <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Andet</item> - <item quantity="other"/> + <item quantity="other">Andet</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Bag kulisserne</item> - <item quantity="other"/> + <item quantity="one">Bag scenen</item> + <item quantity="other">Bag scenerne</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Temasange</item> - <item quantity="other"/> + <item quantity="one">Temasang</item> + <item quantity="other">Temasange</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Temavideoer</item> - <item quantity="other"/> + <item quantity="one">Temavideo</item> + <item quantity="other">Temavideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klip</item> - <item quantity="other"/> + <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Slettede scener</item> - <item quantity="other"/> + <item quantity="one">Slettet scene</item> + <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Interview</item> - <item quantity="other"/> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="one">Scene</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> - <item quantity="one">Prøver</item> - <item quantity="other"/> + <item quantity="one">Smagsprøve</item> + <item quantity="other">Smagsprøver</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Specialindslag</item> - <item quantity="other"/> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilm</item> - <item quantity="other"/> + <item quantity="one">Kort</item> + <item quantity="other">Kort</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -251,70 +253,70 @@ </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="advanced_ui">Avanceret brugerflade</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="auto_play_next_delay">Forsinkelse før Næste afspilles</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="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se og Næste]]></string> + <string name="direct_play_ass">Brug libass til ASS-undertekster</string> + <string name="direct_play_pgs">Afspil PGS-undertekster direkte</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="max_homepage_items">Maks. antal elementer pr. række på startsiden</string> + <string name="nav_drawer_pins_summary">Vælg de elementer, der skal vises som standard. Andre skjules</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_summary_off">Tryk 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="playback_overrides">Overstyring 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="rewatch_next_up">Tillad gensening i Næste</string> + <string name="seek_bar_steps">Trin på afspilningslinjen</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_summary">Vil forsøge at sende til den sidst tilsluttede 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_commercials_behavior">Hvordan reklamer springes 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="skip_intro_behavior">Hvordan introer springes over</string> + <string name="skip_outro_behavior">Hvordan outroer springes over</string> + <string name="skip_previews_behavior">Hvordan forhåndsvisninger springes over</string> + <string name="skip_recap_behavior">Hvordan opsummeringer springes 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_summary">URL, der bruges 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="font_size">Skriftstørrelse</string> + <string name="font_color">Skriftfarve</string> + <string name="font_opacity">Skriftgennemsigtighed</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_opacity">Baggrundsgennemsigtighed</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="player_backend">Afspilningsmotor</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="disable_if_crash">Deaktiver, hvis appen går ned</string> + <string name="mpv_options">MPV-indstillinger</string> + <string name="exoplayer_options">ExoPlayer-indstillinger</string> + <string name="skip_segment_unknown">Spring segment over</string> + <string name="skip_segment_commercial">Spring reklamer over</string> + <string name="skip_segment_preview">Spring forhåndsvisning over</string> + <string name="skip_segment_recap">Spring opsummering over</string> + <string name="skip_segment_outro">Spring outro over</string> + <string name="skip_segment_intro">Spring intro over</string> <string name="played">Afspillet</string> <string name="filter">Filter</string> <string name="year">År</string> @@ -325,30 +327,30 @@ <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="subtitle_margin">Margen</string> <string name="verbose_logging">Detaljeret logning</string> - <string name="enter_pin">Indtast PIN</string> + <string name="enter_pin">Indtast PIN-kode</string> <string name="sign_in_auto">Log ind automatisk</string> - <string name="use_server_credentials">Log ind via server</string> + <string name="use_server_credentials">Log ind via serveren</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="confirm_pin">Bekræft PIN-kode</string> + <string name="incorrect">Forkert</string> + <string name="pin_too_short">PIN-koden skal være på mindst 4 cifre</string> + <string name="will_remove_pin">PIN-koden fjernes</string> + <string name="image_cache_size">Billedcache på disk (MB)</string> + <string name="view_options">Visningsindstillinger</string> <string name="columns">Kolonner</string> - <string name="spacing">Mellemrum</string> - <string name="aspect_ratio">Aspektforhold</string> + <string name="spacing">Afstand</string> + <string name="aspect_ratio">Billedformat</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="cast_and_crew"><![CDATA[Medvirkende og filmhold]]></string> + <string name="guest_stars">Gæsteskuespillere</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="username_or_password">Brug brugernavn og adgangskode</string> <string name="login">Log ind</string> <string name="general">Generelt</string> <string name="container">Container</string> @@ -358,7 +360,7 @@ <string name="level">Niveau</string> <string name="resolution">Opløsning</string> <string name="anamorphic">Anamorfisk</string> - <string name="interlaced">Interlaced</string> + <string name="interlaced">Sammenflettet</string> <string name="framerate">Billedfrekvens</string> <string name="bit_depth">Bitdybde</string> <string name="video_range">Videoområde</string> @@ -386,15 +388,15 @@ <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="sort_channels_recently_watched">Sorter kanaler efter senest sete</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="backdrop_display">Baggrundsbilledstil</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="clear_track_choices">Ryd sporvalg</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> <string name="dts_hd_ma">DTS:HD MA</string> @@ -402,28 +404,28 @@ <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">Afspil Dolby Vision-profil 7 direkte</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="discover">Udforsk</string> + <string name="request">Ønsk</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="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="quick_connect_success">Enheden blev 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="request_4k">Ønsk i 4K</string> + <string name="software_decoding_av1">AV1-softwareafkodning</string> + <string name="upgrade_mpv_toast">MPV er nu standardafspilleren, undtagen ved 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> @@ -434,7 +436,7 @@ <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="save_for_album">Gem for album</string> <string name="play_slideshow">Afspil diasshow</string> <string name="stop_slideshow">Stop diasshow</string> <string name="slideshow_at_beginning">Fra begyndelsen</string> @@ -450,13 +452,13 @@ <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="recently_released_in">Nyligt udgivet 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_server">Indlæs fra serverens brugerprofil</string> + <string name="save_to_server">Gem i serverens brugerprofil</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> @@ -466,10 +468,245 @@ <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="use_thumb_images">Brug miniaturebilleder</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="display_presets">Layoutforudindstillinger</string> + <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at tilpasse alle rækker</string> <string name="customize_home_summary">Vælg rækker og billeder på startsiden</string> <string name="commercial">Reklame</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="movies_title">Film</string> + <string name="profile_specific_settings_from_server">Profilindstillinger fra serveren</string> + <string name="time_left">%1$s tilbage</string> + <string name="trailers_title">Trailere</string> + <string name="tv_shows_title">TV-serier</string> + <string name="other_extras_title">Andet</string> + <string name="behind_the_scenes_title">Bag scenerne</string> + <string name="theme_songs_title">Temasange</string> + <string name="theme_videos_title">Temavideoer</string> + <string name="clips_title">Klip</string> + <string name="deleted_scenes_title">Slettede scener</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Kort</string> + <string name="favorite_items_title">Favorit %s</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minutter</item> + </plurals> + <string name="require_login">Kræv login for profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Deaktiver hardware-offload af lyd</string> + <string name="disable_audio_offload_summary">Deaktivering af hardware-offload for lyddekodning kan afhjælpe frysninger under afspilning på nogle enheder</string> + <string name="combined_search_results">Kombinerede søgeresultater</string> + <string name="combined_search_results_on">Vis alle resultater i en enkelt liste</string> + <string name="combined_search_results_off">Vis resultater efter kategori</string> + <string name="show_voice_search_button">Vis knappen til stemmesøgning</string> + <string name="visible_ui">Synlig</string> + <string name="hidden_ui">Skjult</string> + <string name="results">Resultater</string> + <string name="albums">Albummer</string> + <string name="artists">Kunstnere</string> + <string name="songs">Sange</string> + <string name="go_to_artist">Gå til kunstneren</string> + <string name="now_playing">Afspiller nu</string> + <string name="go_to_album">Gå til albummet</string> + <string name="add_to_queue">Føj til kø</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkod ikke-stereolyd til AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Brug AC3 til surroundlyd</string> + <string name="start_after">Start efter</string> + <string name="animate">Animér</string> + <string name="max_age_rating">Maks. aldersgrænse</string> + <string name="no_max">Ingen maks.</string> + <string name="for_all_ages">For alle aldre</string> + <string name="up_to_age">Op til %s år</string> + <string name="screensaver">Pauseskærm</string> + <string name="screensaver_settings">Pauseskærmsindstillinger</string> + <string name="start_screensaver">Start pauseskærm</string> + <string name="duration">Varighed</string> + <string name="photos">Billeder</string> + <string name="include_types">Inkluder typer</string> + <string name="include_types_summary">Hvilke typer elementer der skal vises billeder for</string> + <string name="content_scale_fit">Tilpas</string> + <string name="content_scale_crop">Beskær</string> + <string name="content_scale_fill">Fyld</string> + <string name="content_scale_fill_width">Fyld bredde</string> + <string name="content_scale_fill_height">Fyld højde</string> + <string name="display_preset_default">Wholphin-standard</string> + <string name="display_preset_compact">Wholphin kompakt</string> + <string name="display_preset_series_thumb">Seriens miniaturebilleder</string> + <string name="display_preset_episode_thumbnails">Episode-miniaturebilleder</string> + <string name="volume_lowest">Laveste</string> + <string name="volume_low">Lav</string> + <string name="volume_medium">Mellem</string> + <string name="volume_high">Høj</string> + <string name="purple">Lilla</string> + <string name="orange">Orange</string> + <string name="black">Sort</string> + <string name="skip_automatically">Spring over automatisk</string> + <string name="skip_ask">Spørg om at springe over</string> + <string name="ffmpeg_fallback">Anvend kun FFmpeg, hvis der ikke findes en indbygget dekoder</string> + <string name="ffmpeg_prefer">Foretræk FFmpeg frem for indbyggede dekodere</string> + <string name="ffmpeg_never">Brug aldrig FFmpeg-dekodere</string> + <string name="next_up_playback_end">Ved afslutningen af afspilningen</string> + <string name="next_up_outro">Under rulletekster/outro</string> + <string name="white">Hvid</string> + <string name="light_gray">Lysegrå</string> + <string name="dark_gray">Mørkegrå</string> + <string name="yellow">Gul</string> + <string name="cyan">Cyanblå</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Omrids</string> + <string name="subtitle_edge_shadow">Skygge</string> + <string name="background_style_wrap">Ombrydning</string> + <string name="background_style_boxed">Indrammet</string> + <string name="prefer_mpv">Foretræk MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Brug ExoPlayer til HDR-afspilning</string> + <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> + <string name="image_type_primary">Primær</string> + <string name="bold_blue">Kraftig blå</string> + <string name="brown">Brun</string> + <string name="skip_ignore">Ignorer</string> + <string name="next_up_never">Aldrig</string> + <string name="external_player">Ekstern afspiller</string> + <string name="image_type_thumb">Miniature</string> + <string name="view_options_type_grid">Gitter</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Kompakt liste</string> + <string name="enter_url_api_key"><![CDATA[Indtast URL og API-nøgle]]></string> + <string name="api_key">API-nøgle</string> + <string name="search_and_download_subtitles"><![CDATA[Søg og download undertekster]]></string> + <string name="no_subtitles_found">Fandt ingen eksterne undertekster</string> + <string name="series_continueing">Nu</string> + <string name="delete_item">Er du sikker på, at du vil slette dette element?</string> + <string name="show_media_management">Vis muligheder for mediehåndtering</string> + <string name="search_for">Søg %s</string> + <string name="actor">Skuespiller</string> + <string name="composer">Komponist</string> + <string name="writer">Manuskriptforfatter</string> + <string name="guest_star">Gæsteskuespiller</string> + <string name="producer">Producer</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstforfatter</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Lydtekniker</string> + <string name="mixer">Mixtekniker</string> + <string name="creator">Skaber</string> + <string name="artist">Kunstner</string> + <string name="album">Album</string> + <string name="album_artist">Albumkunstner</string> + <string name="any_language">Ethvert sprog</string> + <string name="popular_songs">Populære sange</string> + <string name="play_next">Afspil næste</string> + <string name="play_as_type">Afspil som?</string> + <string name="visualizer_rationale">Visualizeren kræver tilladelse til at optage den lyd, der afspilles.</string> + <string name="last_played">Sidst afspillet</string> + <string name="ass_subtitle_mode_libass">Direkte afspilning med libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkte afspilning med ExoPlayers indbyggede understøttelse</string> + <string name="ass_subtitle_playback">Afspilning af SSA/ASS-undertekster</string> + <string name="play_with">Afspil med</string> + <string name="backdrop_style_dynamic">Billede med dynamisk farve</string> + <string name="backdrop_style_image">Kun billede</string> + <string name="seerr_login">Log ind på Seerr-serveren</string> + <string name="seerr_jellyfin_user">Jellyfin-bruger</string> + <string name="seerr_local_user">Lokal bruger</string> + <string name="people_title">Personer</string> + <string name="samples_title">Smagsprøver</string> + <string name="instant_mix">Hurtigt mix</string> + <string name="volume_full">Maks.</string> + <string name="in_app_screensaver">Brug appens egen pauseskærm</string> + <string name="music_videos">Musikvideoer</string> + <string name="lyrics">Sangtekst</string> + <string name="hide_lyrics">Skjul sangtekst</string> + <string name="show_lyrics">Vis sangtekst</string> + <string name="song_has_lyrics">Sangen har sangtekst</string> + <string name="remove_from_queue">Fjern fra kø</string> + <string name="show_album_cover">Vis albumcover</string> + <string name="show_visualizer">Vis visualizer</string> + <string name="show_backdrop">Vis baggrundsbillede</string> + <string name="continue_string">Fortsæt</string> + <string name="select_all">Vælg alle</string> + <string name="separate_types">Adskil typer</string> + <string name="prefer_logos">Foretræk logoer som titler</string> + <string name="updated_toast">Wholphin er opdateret til %s</string> + <string name="remove_continue_watching">Fjern fra Fortsæt med at se</string> + <string name="remove_next_up">Fjern serien fra Næste</string> + <string name="view_removed_next_up">Vis serier fjernet fra Næste</string> + <string name="view_more">Vis mere</string> + <string name="discover_tv">Udforsk TV-serier</string> + <string name="discover_movies">Udforsk film</string> + <string name="studios_in">Studier i %1$s</string> + <string name="system_default">Systemstandard</string> + <string name="ass_subtitle_mode_transcode">Brænd ind/transkod på serveren</string> + <string name="cinema_mode">Biograftilstand</string> + <string name="show_more">Vis %1$s mere</string> + <string name="skip_behavior">Indstillinger for overspringning</string> + <string name="skip_behavior_summary">For introer, rulletekster osv.</string> + <string name="transcoding">Transkodning</string> + <string name="user_interface_language">Sprog for brugerflade</string> + <string name="display_toggles_title">Visning af bedømmelser</string> + <string name="display_toggles_summary">Vælg, hvilke der skal vises</string> + <string name="profile_protection">Profilbeskyttelse</string> + <string name="continue_watching_click_behavior">Handling ved tryk på Fortsæt med at se/Næste</string> + <string name="continue_watching_click_summary_on">Afspil ved tryk</string> + <string name="continue_watching_click_summary_off">Vis detaljer ved tryk</string> + <string name="stars">Stjerner</string> + <string name="tomatoes">Tomater</string> + <string name="sort">Sortér</string> + <string name="split_into_separate_rows">Opdel i separate rækker</string> + <string name="genre_movies">%1$s-film</string> + <string name="genre_tv_shows">%1$s-TV-serier</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Rodmappe</string> + <string name="popularity">Popularitet</string> + <string name="production_status">Produktionsstatus</string> + <string name="different_for_hdr">Brug en anden stil til HDR</string> + <string name="experimental_settings">Eksperimentelle indstillinger</string> + <string name="confirm_enable_experimental_title">Aktivér eksperimentelle indstillinger?</string> + <string name="confirm_enable_experimental_body">Eksperimentelle indstillinger kan være ustabile!\n\nDe kan blive fjernet eller ændret ved enhver udgivelse.</string> + <string name="video_tunneling">Videotunneling</string> + <string name="d_pad_seek_mode_title">D-Pad-tilstand for afspilningslinje</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Lille afspilningslinje nederst på skærmen (standard)</string> + <string name="dpad_seek_mode_trickplay_summary">Afspilningslinje med Trickplay-forhåndsvisningsbilleder</string> + <string name="dpad_seek_mode_none_summary">Ingen afspilningslinje</string> + <string name="blocklisted">På blokeringslisten</string> + <string name="unavailable">Ikke tilgængelig</string> + <string name="subtitle_mode">Underteksttilstand</string> + <string name="use_user_profile">Brug din profilindstilling</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Kun tvungne</string> + <string name="subtitle_mode_always">Altid</string> + <string name="subtitle_mode_none">Ingen</string> + <string name="preferred_subtitle_language">Foretrukket undertekstsprog</string> + <string name="preferred_audio_language">Foretrukket lydsprog</string> + <string name="override_user_profile_settings">Flere profilindstillinger</string> + <string name="override_user_profile_settings_summary">Tilsidesæt dine profilindstillinger fra serveren</string> + <string name="hash_matches">Hash-match</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV-programmer</string> + <string name="recordings">Optagelser</string> + <string name="folders_title">Mapper</string> + <string name="user_views">Brugervisninger</string> + <string name="books">Bøger</string> + <string name="audio_books">Lydbøger</string> + <string name="server_version_not_supported">Serverversionen understøttes ikke</string> + <string name="appears_on">Medvirker på</string> + <string name="dim_after_delay">Dæmp skærmen efter ventetid</string> + <string name="dim_percentage">Dæmpning (%)</string> + <string name="dim_screen">Dæmp skærmen</string> + <string name="photo_albums">Fotoalbummer</string> + <string name="no_favorites_found">Ingen favoritter fundet!</string> </resources> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 60387734..e183feb9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -10,7 +10,7 @@ <string name="bitrate">Bitrate</string> <string name="born">Geboren</string> <string name="cancel_recording">Aufnahme abbrechen</string> - <string name="cancel_series_recording">Serien Aufnahme abbrechen</string> + <string name="cancel_series_recording">Serienaufnahme abbrechen</string> <string name="cancel">Abbrechen</string> <string name="chapters">Kapitel</string> <string name="collection">Sammlung</string> @@ -25,14 +25,14 @@ <string name="died">Gestorben</string> <string name="director">Regisseur</string> <string name="more">Mehr</string> - <string name="movies">Filme</string> + <string name="movies_title">Filme</string> <string name="name">Name</string> <string name="no_data">Keine Daten</string> <string name="no_results">Keine Ergebnisse</string> <string name="no_scheduled_recordings">Keine geplanten Aufnahmen</string> <string name="no_update_available">Kein Update verfügbar</string> <string name="path">Pfad</string> - <string name="people">Darsteller</string> + <string name="people_title">Personen</string> <string name="recently_added_in">Zuletzt hinzugefügt in %1$s</string> <string name="recently_added">Zuletzt hinzugefügt</string> <string name="recently_recorded">Zuletzt aufgenommen</string> @@ -46,24 +46,26 @@ <string name="select_user">Benutzer auswählen</string> <string name="subtitles">Untertitel</string> <string name="switch_servers">Server wechseln</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailer</item> - <item quantity="other"/> + <item quantity="other">Trailer</item> </plurals> <string name="tv_season">Staffel</string> <string name="tv_seasons">Staffeln</string> - <string name="tv_shows">Serien</string> + <string name="tv_shows_title">Serien</string> <string name="ui_interface">Oberfläche</string> <string name="unknown">Unbekannt</string> <string name="updates">Aktualisierungen</string> <string name="version">Version</string> <string name="watch_live">Live ansehen</string> - <string name="years_old">%1$d Jahre alt</string> - <string name="extras">Extras</string> + <plurals name="years_old"> + <item quantity="one">%1$d Jahr alt</item> + <item quantity="other">%1$d Jahre alt</item> + </plurals> + <string name="extras_title">Extras</string> <plurals name="scenes"> - <item quantity="one">Szenen</item> - <item quantity="other"/> + <item quantity="one">Szene</item> + <item quantity="other">Szenen</item> </plurals> <plurals name="downloads"> <item quantity="one">%s Download</item> @@ -81,16 +83,16 @@ <string name="auto_check_for_updates">Automatisch auf Aktualisierungen prüfen</string> <string name="check_for_updates">Auf Aktualisierungen prüfen</string> <string name="settings">Einstellungen</string> - <string name="choose_stream">%1$s Wählen</string> - <string name="clear_image_cache">Bilder Cache leeren</string> - <string name="community_rating">Community Bewertung</string> + <string name="choose_stream">%1$s wählen</string> + <string name="clear_image_cache">Bilder-Cache leeren</string> + <string name="community_rating">Community-Bewertung</string> <string name="compose_error_message_title">Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden.</string> <string name="directed_by">Regie von %1$s</string> <string name="disabled">Deaktiviert</string> <string name="discovered_servers">Gefundene Server</string> <string name="downloading">Herunterladen…</string> <string name="enabled">Aktiviert</string> - <string name="enter_server_url">Serveradresse oder URL inklusive Port eingeben</string> + <string name="enter_server_url">Serveradresse oder URL eingeben</string> <string name="episodes">Episoden</string> <string name="error_loading_collection">Fehler beim Laden der Sammlung %1$s</string> <string name="external_track">Extern</string> @@ -101,13 +103,13 @@ <string name="go_to_series">Gehe zur Serie</string> <string name="go_to">Gehe zu</string> <string name="hide_controller_timeout">Wiedergabesteuerung ausblenden</string> - <string name="hide_debug_info">Debug Infos ausblenden</string> + <string name="hide_debug_info">Debug-Infos ausblenden</string> <string name="hide">Ausblenden</string> <string name="home">Home</string> <string name="immediate">Sofort</string> <string name="intro">Intro</string> <string name="library">Bibliothek</string> - <string name="license_info">Lizenz Informationen</string> + <string name="license_info">Lizenzinformationen</string> <string name="loading">Lädt…</string> <string name="mark_entire_series_as_played">Gesamte Serie als gesehen markieren?</string> <string name="mark_entire_series_as_unplayed">Gesamte Serie als ungesehen markieren?</string> @@ -122,7 +124,7 @@ <string name="playback_speed">Wiedergabegeschwindigkeit</string> <string name="playback">Wiedergabe</string> <string name="preview">Vorschau</string> - <string name="profile_specific_settings">Benutzerprofil Einstellungen</string> + <string name="profile_specific_settings">Benutzerprofil-Einstellungen</string> <string name="queue">Warteschlange</string> <string name="recap">Rückblick</string> <string name="recently_released">Zuletzt veröffentlicht</string> @@ -130,7 +132,7 @@ <string name="record_series">Serie aufnehmen</string> <string name="remove_favorite">Nicht favorisieren</string> <string name="search_and_download"><![CDATA[Suchen & Herunterladen]]></string> - <string name="show_debug_info">Debug Infos anzeigen</string> + <string name="show_debug_info">Debug-Infos anzeigen</string> <string name="download_and_update"><![CDATA[Herunterladen & Aktualisieren]]></string> <string name="shuffle">Zufällig</string> <string name="skip">Überspringen</string> @@ -157,15 +159,15 @@ <string name="official_rating">Altersfreigabe</string> <plurals name="other_extras"> <item quantity="one">Sonstiges</item> - <item quantity="other"/> + <item quantity="other">Sonstige</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Hinter den Kulissen</item> - <item quantity="other"/> + <item quantity="other">Hinter den Kulissen</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Gelöschte Szenen</item> - <item quantity="other"/> + <item quantity="one">Nicht verwendete Szene</item> + <item quantity="other">Nicht verwendete Szenen</item> </plurals> <plurals name="items"> <item quantity="one">%d Eintrag</item> @@ -175,15 +177,15 @@ <string name="advanced_ui">Erweiterte Oberfläche</string> <string name="app_theme">Design</string> <string name="auto_play_next_delay">Verzögerung vor dem nächsten Titel</string> - <string name="auto_play_next">Automatisch als nächstes wiedergeben</string> + <string name="auto_play_next">Automatisch als Nächstes wiedergeben</string> <string name="install_update">Aktualisierung installieren</string> <string name="installed_version">Installierte Version</string> - <string name="max_homepage_items">Max. Anzahl Objekte auf der Startseite</string> + <string name="max_homepage_items">Max. Anzahl Objekte je Startseiten-Reihe</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ</string> - <string name="live_tv">Live TV</string> - <string name="outro">Outro</string> - <string name="play_count">Wiedergabe Anzahl</string> - <string name="playback_debug_info">Wiedergabe Debug Infos anzeigen</string> + <string name="live_tv">Live-TV</string> + <string name="outro">Abspann</string> + <string name="play_count">Wiedergabeanzahl</string> + <string name="playback_debug_info">Wiedergabe Debug-Infos anzeigen</string> <string name="playlist">Wiedergabeliste</string> <string name="playlists">Wiedergabelisten</string> <string name="show_next_up_when">Zeige als nächstes</string> @@ -191,11 +193,11 @@ <string name="sort_by_name">Name</string> <string name="studios">Studios</string> <string name="tv_dvr_schedule">Aufnahmeplan</string> - <string name="video_scale">Video Skalierung</string> + <string name="video_scale">Videoskalierung</string> <string name="video">Video</string> <string name="videos">Videos</string> <string name="aired_episode_order">Nach Sendedatum sortiert</string> - <string name="one_click_pause_summary_on">Drücke D-Pad Mitte zum Pausieren/Fortsetzen</string> + <string name="one_click_pause_summary_on">Drücke Steuerkreuz Mitte zum Pausieren/Fortsetzen</string> <string name="italic_font">Kursivschrift</string> <string name="runtime_sort">Laufzeit</string> <plurals name="theme_songs"> @@ -215,12 +217,12 @@ <item quantity="other">Interviews</item> </plurals> <plurals name="samples"> - <item quantity="one">Beispiele</item> - <item quantity="other"/> + <item quantity="one">Ausschnitt</item> + <item quantity="other">Ausschnitte</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Begleitfilme</item> - <item quantity="other"/> + <item quantity="one">Begleitfilm</item> + <item quantity="other">Begleitfilme</item> </plurals> <plurals name="shorts"> <item quantity="one">Short</item> @@ -228,23 +230,23 @@ </plurals> <string name="combine_continue_next_summary">Trifft nur auf Serien zu</string> <string name="direct_play_ass">Libass für ASS Untertitel verwenden</string> - <string name="direct_play_pgs">Direktes abspielen von PGS Untertitel</string> + <string name="direct_play_pgs">Direktes Abspielen von PGS-Untertiteln</string> <string name="downmix_stereo">Immer zu Stereo heruntermischen</string> - <string name="ffmpeg_extension_pref">FFmpeg decoder Modul verwenden</string> - <string name="global_content_scale">Standard Skalierung</string> + <string name="ffmpeg_extension_pref">FFmpeg-Dekoder-Modul verwenden</string> + <string name="global_content_scale">Standard-Skalierung</string> <string name="nav_drawer_switch_on_focus_summary_off">Klicken um die Seite zu wechseln</string> <string name="pass_out_protection">Einschlafschutz</string> - <string name="play_theme_music">Themenmusik wiedergeben</string> + <string name="play_theme_music">Titelmusik wiedergeben</string> <string name="remember_selected_tab">Ausgewählte Tabs merken</string> <string name="send_app_logs_summary">Nützlich zum Debuggen</string> <string name="send_crash_reports">Sende Absturzmeldung</string> - <string name="skip_back_on_resume_preference">Zurückspringen wenn wiedergabe fortgesetzt wird</string> + <string name="skip_back_on_resume_preference">Zurückspringen, wenn Wiedergabe fortgesetzt wird</string> <string name="skip_commercials_behavior">Werbung überspringen</string> <string name="skip_forward_preference">Vorspringen</string> - <string name="skip_intro_behavior">Überspringe Intro verhalten</string> - <string name="skip_outro_behavior">Überspringe Outro verhalten</string> - <string name="skip_previews_behavior">Überspringe Vorschau verhalten</string> - <string name="skip_recap_behavior">Überspringe Rückblick verhalten</string> + <string name="skip_intro_behavior">Überspringe Intro</string> + <string name="skip_outro_behavior">Überspringe Abspann</string> + <string name="skip_previews_behavior">Überspringe Vorschau</string> + <string name="skip_recap_behavior">Überspringe Rückblick</string> <string name="update_available">Aktualisierung verfügbar</string> <string name="update_url_summary">Verwendete URL um auf Aktualisierungen zu prüfen</string> <string name="update_url">Aktualisierungs-URL</string> @@ -254,32 +256,32 @@ <string name="edge_style">Stil der Umrandung</string> <string name="edge_color">Farbe der Umrandung</string> <string name="background_opacity">Deckkraft des Hintergrundes</string> - <string name="background_style">Hintergrund Stil</string> - <string name="subtitle_style">Untertitel Stil</string> + <string name="background_style">Hintergrund-Stil</string> + <string name="subtitle_style">Untertitelstil</string> <string name="background_color">Hintergrundfarbe</string> <string name="reset">Zurücksetzen</string> <string name="bold_font">Fettschrift</string> - <string name="mpv_hardware_decoding">MPV: Hardware dekodierung verwenden</string> - <string name="disable_if_crash">Deaktivieren, wenn abstürze auftreten</string> + <string name="mpv_hardware_decoding">MPV: Hardware-Dekodierung verwenden</string> + <string name="disable_if_crash">Deaktivieren, wenn Abstürze auftreten</string> <string name="mpv_options">MPV Optionen</string> <string name="exoplayer_options">ExoPlayer Optionen</string> - <string name="combine_continue_next"><![CDATA[Kombiniere Weiterschauen & Als nächstes]]></string> + <string name="combine_continue_next"><![CDATA[Kombiniere Weiterschauen & Als Nächstes]]></string> <string name="nav_drawer_pins_summary">Wähle die Standardobjekte zur Anzeige aus, andere werden ausgeblendet</string> - <string name="nav_drawer_pins">Navigationsmenü Objekte anpassen</string> - <string name="nav_drawer_switch_on_focus">Navigationsmenüseiten beim Fokus wechseln</string> + <string name="nav_drawer_pins">Navigationsmenü anpassen</string> + <string name="nav_drawer_switch_on_focus">Navigationsmenü-Seiten beim Fokus wechseln</string> <string name="playback_overrides">Wiedergabe überschreiben</string> - <string name="seek_bar_steps">Zeitleiste Sprünge</string> + <string name="seek_bar_steps">Zeitleistensprünge</string> <string name="send_app_logs">Sende Logs zum aktuellen Server</string> <string name="send_crash_reports_summary">Versucht zum letzten verbundenen Server zu senden</string> <string name="skip_back_preference">Zurückspringen</string> - <string name="player_backend">Wiedergabe backend</string> - <string name="rewatch_next_up">Aktiviere erneut wiedergeben in als nächstes</string> - <string name="skip_segment_unknown">Segment Überspringen</string> - <string name="skip_segment_commercial">Werbung Überspringen</string> - <string name="skip_segment_preview">Vorschau Überspringen</string> - <string name="skip_segment_recap">Rückblick Überspringen</string> - <string name="skip_segment_outro">Outro Überspringen</string> - <string name="skip_segment_intro">Intro Überspringen</string> + <string name="player_backend">Wiedergabe-Backend</string> + <string name="rewatch_next_up">Aktiviere erneut wiedergeben in als Nächstes</string> + <string name="skip_segment_unknown">Segment überspringen</string> + <string name="skip_segment_commercial">Werbung überspringen</string> + <string name="skip_segment_preview">Vorschau überspringen</string> + <string name="skip_segment_recap">Rückblick überspringen</string> + <string name="skip_segment_outro">Abspann überspringen</string> + <string name="skip_segment_intro">Intro überspringen</string> <string name="filter">Filter</string> <string name="year">Jahr</string> <string name="decade">Jahrzehnt</string> @@ -287,7 +289,7 @@ <string name="dolby_vision">Dolby Vision</string> <string name="dolby_atmos">Dolby Atmos</string> <string name="ends_at">Endet um %1$s</string> - <string name="enter_server_address">Server Adresse eingeben</string> + <string name="enter_server_address">Serveradresse eingeben</string> <string name="no_servers_found">Keine Server gefunden</string> <string name="media_information">Medieninformation</string> <string name="played">Abgespielt</string> @@ -307,7 +309,7 @@ <string name="spacing">Abstände</string> <string name="aspect_ratio">Seitenverhältnis</string> <string name="show_details">Details anzeigen</string> - <string name="image_type">Bild Typ</string> + <string name="image_type">Bildertyp</string> <string name="automatic">Automatisch</string> <string name="username_or_password">Verwende Benutzername/Passwort</string> <string name="login">Anmelden</string> @@ -325,7 +327,7 @@ <string name="no_trailers">Keine Trailer</string> <string name="play_trailer">Trailer abspielen</string> <string name="local">Lokal</string> - <string name="verbose_logging">detaillierte Protokollierung</string> + <string name="verbose_logging">Detailliertes Protokoll</string> <string name="use_server_credentials">Login via Server</string> <string name="press_enter_to_confirm">Drücke die mittlere Taste zur Bestätigung</string> <string name="image_cache_size">Größe des Festplatten-Caches für Bilder (MB)</string> @@ -344,21 +346,21 @@ <string name="sample_rate_unit">Hz</string> <string name="only_forced_subtitles">Nur erzwungene Untertitel</string> <string name="voice_starting">Beginne</string> - <string name="processing">verarbeite</string> + <string name="processing">Verarbeite</string> <string name="voice_error_permissions">Mikrofonzugriff erforderlich</string> <string name="voice_error_network">Netzwerkfehler</string> - <string name="voice_error_network_timeout">Netzwerk Zeitüberschreitung</string> + <string name="voice_error_network_timeout">Netzwerk-Zeitüberschreitung</string> <string name="voice_error_server">Serverfehler</string> - <string name="voice_error_unknown">unbekannter Fehler</string> - <string name="retry">wiederholen</string> - <string name="layout">🇩🇪 Layout</string> + <string name="voice_error_unknown">Unbekannter Fehler</string> + <string name="retry">Wiederholen</string> + <string name="layout">Layout</string> <string name="sample_rate">Abtastrate</string> <string name="dts">DTS</string> <string name="discover">Entdecke</string> <string name="request">Anfrage</string> <string name="pending">Ausstehend</string> - <string name="seerr_integration">Seerr Integration</string> - <string name="seerr_server_added">Seerr server hinzugefügt</string> + <string name="seerr_integration">Seerr-Integration</string> + <string name="seerr_server_added">Seerr-Server hinzugefügt</string> <string name="password">Passwort</string> <string name="username">Benutzername</string> <string name="url">URL</string> @@ -372,31 +374,31 @@ <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.\nDu kannst das in den Einstellungen ändern.</string> + <string name="upgrade_mpv_toast">MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst dies 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> - <string name="upcoming_movies">kommende Filme</string> - <string name="upcoming_tv">kommende Serien</string> - <string name="force_dovi_profile_7">Direktes abspielen von Dolby Vision Profil 7</string> + <string name="force_dovi_profile_7_summary">Ignoriert Gerätekompatibilitätsprüfungen</string> + <string name="upcoming_movies">Kommende Filme</string> + <string name="upcoming_tv">Kommende Serien</string> + <string name="force_dovi_profile_7">Direktes Abspielen von Dolby Vision Profil 7</string> <string name="subtitle_delay">Untertitelverzögerung</string> <string name="sort_channels_recently_watched">Sortiere Kanäle nach zuletzt geschaut</string> - <string name="favorite_channels_at_beginning">Favoriten als erstes anzeigen</string> + <string name="favorite_channels_at_beginning">Favoriten als Erstes anzeigen</string> <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_audio">Audio-Aufnahme-Fehler</string> + <string name="voice_error_no_match">Keine Spracheingabe erkannt</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="video_range_type">Videobereich-Typ</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="anamorphic">Anamorph</string> <string name="color_space">Farbraum</string> <string name="color_transfer">Farbübertragung</string> <string name="color_primaries">Primärfarben</string> @@ -404,11 +406,11 @@ <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="guest_stars">Gastdarsteller</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="no_more_images">Keine weiteren 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> @@ -431,7 +433,7 @@ <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="max_days_next_up">Max. Tage in \"Als Nächstes\"</string> <string name="quick_connect">Quick Connect</string> <plurals name="minutes"> <item quantity="one">%s Minute</item> @@ -444,7 +446,7 @@ <string name="no_limit">Keine Begrenzung</string> <string name="add_row">Reihe hinzufügen</string> <string name="genres_in">Genres in %1$s</string> - <string name="recently_released_in">kürzlich veröffentlicht in %1$s</string> + <string name="recently_released_in">Kürzlich veröffentlicht in %1$s</string> <string name="height">Höhe</string> <string name="apply_all_rows">Auf alle Reihen anwenden</string> <string name="add_row_for">Füge Reihe für %1$s hinzu</string> @@ -456,25 +458,25 @@ <string name="for_episodes">Für Folgen</string> <string name="display_presets_description">Vorlagen um schnell alle Reihen zu gestalten</string> <string name="customize_home_summary">Wähle Reihen und Bilder auf der Homepage</string> - <string name="start_after">Starte anschließend</string> - <string name="display_preset_series_thumb">Serien Vorschaubilder</string> - <string name="display_preset_episode_thumbnails">Episoden Vorschaubilder</string> - <string name="seerr_login">Einloggen zum Seerr Server</string> - <string name="seerr_jellyfin_user">Jellyfin User</string> - <string name="show_media_management">Zeige Media Management Optionen</string> + <string name="start_after">Starte nach</string> + <string name="display_preset_series_thumb">Serien-Vorschaubilder</string> + <string name="display_preset_episode_thumbnails">Episoden-Vorschaubilder</string> + <string name="seerr_login">Am Seerr-Server einloggen</string> + <string name="seerr_jellyfin_user">Jellyfin-Nutzer</string> + <string name="show_media_management">Medienmanagement-Optionen anzeigen</string> <string name="decrease_all_cards_size">Größe für alle Karten reduzieren</string> <string name="settings_saved">Einstellungen gespeichert</string> <string name="max_age_rating">Maximale Altersfreigabe</string> <string name="no_max">Keine Begrenzung</string> <string name="for_all_ages">Ohne Altersbeschränkung</string> - <string name="up_to_age">Ab %s Jahre</string> + <string name="up_to_age">Bis %s Jahre</string> <string name="screensaver">Bildschirmschoner</string> - <string name="screensaver_settings">Bildschirmschoner-Einstellungen</string> + <string name="screensaver_settings">Bildschirmschoner Einstellungen</string> <string name="start_screensaver">Bildschirmschoner starten</string> - <string name="duration">Länge</string> + <string name="duration">Dauer</string> <string name="photos">Fotos</string> - <string name="include_types">Arten einschließen</string> - <string name="include_types_summary">Für welche Arten von Einträgen sollen Bilder angezeigt werden</string> + <string name="include_types">Medientypen einschließen</string> + <string name="include_types_summary">Für welche Medientypen sollen Bilder angezeigt werden</string> <string name="content_scale_fit">Angepasst</string> <string name="content_scale_crop">Zugeschnitten</string> <string name="content_scale_fill">Ausgefüllt</string> @@ -485,7 +487,7 @@ <string name="ffmpeg_prefer">FFmpeg statt der vorhandenen Decoder verwenden</string> <string name="ffmpeg_never">FFmpeg Decoder nie verwenden</string> <string name="next_up_playback_end">Am Ende einer Playlist</string> - <string name="next_up_outro">Während der Credits/Outro</string> + <string name="next_up_outro">Während des Abspanns</string> <string name="white">Weiß</string> <string name="light_gray">Hellgrau</string> <string name="dark_gray">Dunkelgrau</string> @@ -495,14 +497,14 @@ <string name="subtitle_edge_outline">Umrandung</string> <string name="subtitle_edge_shadow">Schatten</string> <string name="prefer_mpv">MPV bevorzugen</string> - <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer für die HDR Wiedergabe verwenden</string> + <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer für HDR-Wiedergabe verwenden</string> <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">Quadrat (1:1)</string> <string name="image_type_primary">Primär</string> <string name="backdrop_style_dynamic">Bild mit dynamischen Farben</string> - <string name="api_key">API Schlüssel</string> + <string name="api_key">API-Schlüssel</string> <string name="seerr_local_user">Lokaler Benutzer</string> <string name="search_for">Suche %s</string> <string name="display_presets">Design-Vorlagen</string> @@ -513,7 +515,7 @@ <string name="display_preset_compact">Wholphin Kompakt</string> <string name="volume_lowest">Niedrigste</string> <string name="volume_low">Niedrig</string> - <string name="volume_medium">mittel</string> + <string name="volume_medium">Mittel</string> <string name="volume_high">Hoch</string> <string name="volume_full">Voll</string> <string name="purple">Lila</string> @@ -521,20 +523,20 @@ <string name="black">Schwarz</string> <string name="backdrop_style_image">Nur Bild</string> <string name="series_continueing">Aktuell</string> - <string name="in_app_screensaver">Eingebauten Bildschirmschoner nutzen</string> + <string name="in_app_screensaver">Integrierten Bildschirmschoner nutzen</string> <string name="delete_item">Bist du sicher, dass du diesen Eintrag löschen möchtest?</string> - <string name="actor">Schauspieler</string> + <string name="actor">Darsteller</string> <string name="composer">Komponist</string> <string name="writer">Autor</string> - <string name="guest_star">Nebenrolle</string> + <string name="guest_star">Gastdarsteller</string> <string name="producer">Produzent</string> <string name="conductor">Dirigent</string> - <string name="lyricist">Liedtext-Schreiber</string> + <string name="lyricist">Liedtexter</string> <string name="artist">Künstler</string> - <string name="favorite_items">Favorisierte %s</string> - <string name="home_rows">Startseite - Reihen</string> - <string name="load_from_server">Vom Server Benutzerprofil laden</string> - <string name="save_to_server">Im Server Benutzerprofil speichern</string> + <string name="favorite_items_title">Favorisierte %s</string> + <string name="home_rows">Startseiten-Reihen</string> + <string name="load_from_server">Vom Server-Benutzerprofil laden</string> + <string name="save_to_server">Im Server-Benutzerprofil speichern</string> <string name="load_from_web_client">Vom Webclient laden</string> <string name="increase_all_cards_size">Größe für alle Karten erhöhen</string> <string name="use_thumb_images">Vorschaubilder benutzen</string> @@ -542,10 +544,10 @@ <string name="arranger">Arrangeur</string> <string name="engineer">Ingenieur</string> <string name="bold_blue">Dunkelblau</string> - <string name="image_subtitle_opacity">Transparenz des Bilduntertitels</string> + <string name="image_subtitle_opacity">Deckkraft des Bilduntertitels</string> <string name="image_type_thumb">Vorschaubild</string> - <string name="use_series">Serien Vorschaubilder verwenden</string> - <string name="enter_url_api_key"><![CDATA[URL & API Key eingeben]]></string> + <string name="use_series">Serien-Vorschaubilder verwenden</string> + <string name="enter_url_api_key"><![CDATA[URL & API-Schlüssel eingeben]]></string> <string name="search_and_download_subtitles"><![CDATA[Untertitel suchen & herunterladen]]></string> <string name="creator">Ersteller</string> <string name="background_style_wrap">Textmarker</string> @@ -560,52 +562,52 @@ <string name="instant_mix">Sofortmix</string> <string name="go_to_album">Zu Album wechseln</string> <string name="add_to_queue">Zur Warteschlange hinzufügen</string> - <string name="album_artist">Album Interpret</string> + <string name="album_artist">Albumkünstler</string> <string name="album">Album</string> <string name="popular_songs">Beliebte Lieder</string> - <string name="play_next">Als nächstes abspielen</string> + <string name="play_next">Als Nächstes abspielen</string> <string name="music_videos">Musikvideos</string> <string name="lyrics">Liedtexte</string> <string name="hide_lyrics">Liedtexte ausblenden</string> <string name="show_lyrics">Liedtexte einblenden</string> <string name="song_has_lyrics">Lied hat Liedtext</string> <string name="remove_from_queue">Aus Warteschlange entfernen</string> - <string name="show_album_cover">Album Cover anzeigen</string> + <string name="show_album_cover">Albumcover anzeigen</string> <string name="show_visualizer">Visualizer anzeigen</string> <string name="show_backdrop">Hintergrundbild anzeigen</string> <string name="play_as_type">Abspielen als?</string> <string name="visualizer_rationale">Die Visualisierung der aktuellen Wiedergabe erfordert die Berechtigung zur Audioaufzeichnung.</string> <string name="continue_string">Fortfahren</string> <string name="separate_types">Verschiedene Arten</string> - <string name="prefer_logos">Logos anstatt Titelnamen anzeigen</string> + <string name="prefer_logos">Bevorzugt Logo statt Titel anzeigen</string> <string name="updated_toast">Wholphin aktualisiert auf %s</string> <string name="remove_continue_watching">Von \"Weiterschauen\" entfernen</string> - <string name="remove_next_up">Serie aus \"Als nächstes Anschauen\" entfernen</string> + <string name="remove_next_up">Serie aus \"Als Nächstes anschauen\" entfernen</string> <string name="view_more">Mehr anzeigen</string> - <string name="discover_tv">TV Serien entdecken</string> + <string name="discover_tv">TV-Serien entdecken</string> <string name="discover_movies">Filme entdecken</string> <string name="studios_in">Studios in %1$s</string> - <string name="view_removed_next_up">Serien anzeigen, die von \"Als nächstes Anschauen\" entfernt wurden</string> + <string name="view_removed_next_up">Serien anzeigen, die von \"Als Nächstes anschauen\" entfernt wurden</string> <string name="next_up_never">Nie</string> <string name="last_played">Zuletzt wiedergegeben</string> - <string name="ass_subtitle_mode_libass">Direkte Wiedergabe mit Libass</string> - <string name="ass_subtitle_mode_exoplayer">Direkte Wiedergabe mit ExoPlayer (eingebaut)</string> - <string name="ass_subtitle_mode_transcode">Einbrennen/Auf Server transkodieren</string> - <string name="ass_subtitle_playback">SSA/ASS Untertitel Wiedergabe</string> + <string name="ass_subtitle_mode_libass">Direkte Wiedergabe mit libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkte Wiedergabe mit ExoPlayer (integriert)</string> + <string name="ass_subtitle_mode_transcode">Einbrennen/auf Server transkodieren</string> + <string name="ass_subtitle_playback">SSA/ASS Untertitel-Wiedergabe</string> <string name="external_player">Externer Player</string> <string name="system_default">Systemstandard</string> <string name="show_more">Zeige %1$s mehr</string> <string name="skip_behavior">\"Überspringen\" Verhalten</string> - <string name="skip_behavior_summary">Für Intros, Credits, etc</string> + <string name="skip_behavior_summary">Für Intros, Credits, etc.</string> <string name="cinema_mode">Kinomodus</string> <string name="play_with">Abspielen mit</string> <string name="transcoding">Transkodieren</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Zusammengefasste Suchergebnisse</string> <string name="combined_search_results_on">Alle Ergebnisse in einer gemeinsamen Liste anzeigen</string> <string name="combined_search_results_off">Ergebnisse nach Kategorie anzeigen</string> <string name="results">Ergebnisse</string> - <string name="require_login">Anmeldung erforderlich für das Profil</string> + <string name="require_login">Profil benötigt Anmeldung</string> <string name="visible_ui">Sichtbar</string> <string name="hidden_ui">Versteckt</string> <string name="view_options_type_grid">Raster</string> @@ -614,11 +616,104 @@ <string name="show_voice_search_button">Zeige Sprachsuche-Button</string> <string name="user_interface_language">Sprache Benutzeroberfläche</string> <string name="profile_protection">Profilschutz</string> - <string name="continue_watching_click_behavior">Weiterschauen/Als nächstes Verhalten</string> + <string name="continue_watching_click_behavior">Klickverhalten bei Weiterschauen/Als nächstes</string> <string name="continue_watching_click_summary_on">Mit Klick abspielen</string> <string name="continue_watching_click_summary_off">Mit Klick Details zeigen</string> <string name="stars">Sterne</string> <string name="tomatoes">Tomaten</string> - <string name="display_toggles_title">Zeige Bewertungen</string> - <string name="display_toggles_summary">Anpassen was angezeigt wird</string> + <string name="display_toggles_title">Bewertungen anzeigen</string> + <string name="display_toggles_summary">Anpassen, was angezeigt wird</string> + <string name="sort">Sortieren</string> + <string name="split_into_separate_rows">In unterschiedliche Reihen aufteilen</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Andere</string> + <string name="behind_the_scenes_title">Behind the Scenes</string> + <string name="theme_songs_title">Titelsongs</string> + <string name="theme_videos_title">Titelvideos</string> + <string name="clips_title">Ausschnitte</string> + <string name="deleted_scenes_title">Gelöschte Szenen</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Szenen</string> + <string name="samples_title">Probe</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Shorts</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filme</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Serien</item> + </plurals> + <plurals name="people"> + <item quantity="one">Darsteller</item> + <item quantity="other">Darsteller</item> + </plurals> + <string name="genre_movies">%1$s Filme</string> + <string name="genre_tv_shows">%1$s Serien</string> + <string name="quality_profile">Qualitätsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Wurzelordner</string> + <string name="popularity">Beliebtheit</string> + <string name="production_status">Produktionsstatus</string> + <string name="different_for_hdr">Für HDR anderen Stiel verwenden</string> + <string name="experimental_settings">Experimentelle Einstellungen</string> + <string name="confirm_enable_experimental_title">Experimentelle Einstellungen aktivieren?</string> + <string name="confirm_enable_experimental_body">Die experimentellen Einstellungen sind instabil!\n\n Diese könnten mit jedem Update verändert oder entfernt werden.</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s verbleibend</string> + <string name="video_tunneling">Video Tunneling</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Nicht-Stereo Audio zu AC3/Dolby Digital transcodieren]]></string> + <string name="prefer_ac3_for_surround">AC3 für Surround Sound bevorzugen</string> + <string name="blocklisted">Auf Blockliste hinzugefügt</string> + <string name="unavailable">Nicht verfügbar</string> + <string name="profile_specific_settings_from_server">Benutzerprofil-Einstellungen auf dem Server</string> + <string name="subtitle_mode">Modus Untertitel</string> + <string name="use_user_profile">Nutze deine Profileinstellungen</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_only_forced">Nur Forced</string> + <string name="subtitle_mode_always">Immer</string> + <string name="subtitle_mode_none">Keine</string> + <string name="preferred_subtitle_language">Bevorzugte Untertitelsprache</string> + <string name="preferred_audio_language">Bevorzugte Audiosprache</string> + <string name="override_user_profile_settings">Weitere Benutzerprofil-Einstellungen</string> + <string name="override_user_profile_settings_summary">Überschreibe deine Benutzerprofileinstellungen mit denen vom Server</string> + <string name="any_language">Beliebige Sprache</string> + <string name="hash_matches">Hash Übereinstimmungen</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programme</string> + <string name="recordings">Aufnahmen</string> + <string name="folders_title">Ordner</string> + <string name="books">Bücher</string> + <string name="audio_books">Hörbücher</string> + <string name="d_pad_seek_mode_title">D-Pad Suchmodus</string> + <string name="dpad_seek_mode_trickplay">Trickplay Vorschau</string> + <string name="user_views">Benutzeransicht</string> + <string name="appears_on">Erscheint auf</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Kleine Suchleiste am unteren Rand des Bildschirms (Standard)</string> + <string name="brown">Braun</string> + <string name="dpad_seek_mode_trickplay_summary">Suchleiste mit Trickplay Vorschaubildern</string> + <string name="dpad_seek_mode_none_summary">Keine Suchleiste</string> + <string name="server_version_not_supported">Nicht unterstützte Server-Version</string> + <string name="disable_audio_offload">Audio-Hardware-Offload deaktivieren</string> + <string name="disable_audio_offload_summary">Das Auslagern der Audio Decodierung zu deaktivieren könnte Wiedergabeprobleme auf einigen Gräten beheben</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d F%2$d</string> + <string name="season_episode_number_range">S%1$d F%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dF%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dF%2$02d-E%3$02d</string> + <string name="episode_number_short">F%1$d</string> + <string name="season_number">Staffel %1$d</string> + <string name="dim_after_delay">Bildschirm nach Verzögerung dimmen</string> + <string name="dim_percentage">Dimmen in Prozent</string> + <string name="dim_screen">Bildschirm dimmen</string> + <string name="photo_albums">Fotoalben</string> + <string name="no_favorites_found">Keine Favoriten vorhanden!</string> </resources> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 6159ff33..732ffe5d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="movies">Ταινίες</string> + <string name="movies_title">Ταινίες</string> <string name="profile">Προφίλ</string> <string name="home">Αρχική</string> <string name="language">Γλώσσα</string> @@ -18,21 +18,21 @@ <string name="videos">Τα βίντεο</string> <string name="background">Παρασκήνιο</string> <plurals name="theme_songs"> - <item quantity="one">Θεματικά τραγούδια</item> - <item quantity="other"/> + <item quantity="one">Θεματικό τραγούδι</item> + <item quantity="other">Θεματικά τραγούδια</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Θεματικά βίντεο</item> - <item quantity="other"/> + <item quantity="one">Θεματικό βίντεο</item> + <item quantity="other">Θεματικά βίντεο</item> </plurals> - <string name="next_up">Επόμενο</string> - <string name="rewatch_next_up">Ενεργοποιήστε την επανάληψη παρακολούθησης στα \'Επόμενα\'</string> + <string name="next_up">Επόμενα</string> + <string name="rewatch_next_up">Ενεργοποιήστε την επαναπροβολή στα \'Επόμενα\'</string> <string name="font">Γραμματοσειρά</string> <string name="font_size">Μέγεθος Κειμένου</string> <string name="font_color">Χρώμα Κειμένου</string> - <string name="bold_font">Φωτεινότητα κειμένου</string> - <string name="library">Βιβλιοθήκης</string> - <string name="tv_shows">Σειρές</string> + <string name="bold_font">Έντονη γραμματοσειρά</string> + <string name="library">Βιβλιοθήκη</string> + <string name="tv_shows_title">Σειρές</string> <string name="genres">Είδη</string> <string name="collection">Συλλογή</string> <string name="collections">Συλλογές</string> @@ -42,26 +42,26 @@ <string name="director">Σκηνοθέτης</string> <string name="studios">Στούντιο</string> <string name="sdr">SDR</string> - <string name="cast_and_crew"><![CDATA[Ηθοποιοί και Συνεργείο]]></string> - <string name="more_like_this">Περισσότερα Σαν Αυτό</string> + <string name="cast_and_crew">Ηθοποιοί και Συνεργείο</string> + <string name="more_like_this">Παραπλήσια</string> <string name="episodes">Επεισόδια</string> <string name="jump_letters">#ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ</string> <plurals name="downloads"> <item quantity="one">%s λήψη</item> <item quantity="other">%s λήψεις</item> </plurals> - <string name="downloading">λήψη…</string> + <string name="downloading">Γίνεται λήψη…</string> <string name="path">Διαδρομή</string> <string name="name">Όνομα</string> - <string name="container">Δοχείο</string> + <string name="container">Κοντέινερ</string> <string name="file_size">Μέγεθος</string> <string name="codec">Κωδικοποιητής</string> <string name="avc">AVC</string> <string name="bitrate">Ρυθμός bit</string> <string name="bit_depth">Βάθος bit</string> <string name="bit_unit">bit</string> - <string name="forced_track">Εξαναγκασμός</string> - <string name="layout">Σχέδιο</string> + <string name="forced_track">Αναγκαστικό</string> + <string name="layout">Διάταξη</string> <string name="default_track">Προεπιλογή</string> <string name="external_track">Εξωτερικό</string> <string name="add_user">Προσθήκη χρήστη</string> @@ -69,25 +69,25 @@ <string name="chapters">Κεφάλαια</string> <string name="choose_stream">Διάλεξε %1$s</string> <string name="add_server">Προσθήκη διακομιστή</string> - <string name="cancel_recording">Ακύρωση καταχώρησης</string> - <string name="cancel_series_recording">Ακύρωση σειρά καταχώρησης</string> + <string name="cancel_recording">Ακύρωση καταγραφής</string> + <string name="cancel_series_recording">Ακύρωση καταγραφής σειράς</string> <string name="clear_image_cache">Καθαρισμός μνήμης εικόνας</string> <string name="commercial">Διαφήμιση</string> <string name="community_rating">Αξιολόγηση κοινού</string> <string name="compose_error_message_title">Ένα σφάλμα εμφανίστηκε! Πατήστε το κομπί για να στείλετε αρχείο καταγραφής στο διακομιστή.</string> <string name="confirm">Επικύρωση</string> - <string name="continue_watching">Συνεχίστε να βλέπετε</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="delete">Διαγραφή</string> <string name="died">Πέθανε</string> <string name="directed_by">Σκηνοθετήθηκε από %1$s</string> - <string name="disabled">Απενεργοποιημένος</string> - <string name="enabled">Ενεργοποιημένος</string> - <string name="enter_server_url">Καταχώρηση διακομιστή IP or URL</string> + <string name="disabled">Απενεργοποιημένο</string> + <string name="enabled">Ενεργοποιημένο</string> + <string name="enter_server_url">Δώσε την IP ή το URL του διακομιστή</string> <string name="error_loading_collection">Σφάλμα φόρτωσης συλλογής %1$s</string> - <string name="go_to_series">Πηγαίνετε στις σειρές</string> - <string name="go_to">Πηγαίνετε στο</string> + <string name="go_to_series">Πήγαινε στη σειρά</string> + <string name="go_to">Πήγαινε σε αυτό</string> <string name="hide_controller_timeout">Απόκρυψη χειρισμού αναπαραγωγής</string> <string name="hide_debug_info">Απόκρυψη πληροφοριών εντοπισμού σφαλμάτων</string> <string name="hide">Απόκρυψη</string> @@ -96,19 +96,19 @@ <string name="license_info">Πληροφορίες άδειας</string> <string name="live_tv">Ζωντανή Τηλεόραση</string> <string name="loading">Φόρτωση…</string> - <string name="mark_entire_series_as_played">Επισήμανση ολόκληρης σειράς ως αναπαραγμένης?</string> - <string name="mark_entire_series_as_unplayed">Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης?</string> + <string name="mark_entire_series_as_played">Επισήμανση ολόκληρης σειράς ως αναπαραγμένης;</string> + <string name="mark_entire_series_as_unplayed">Επισήμανση ολόκληρης σειράς ως μη αναπαραγμένης;</string> <string name="mark_unwatched">Επισήμανση ως μη ολοκληρωμένο</string> <string name="mark_watched">Επισήμανση ως ολοκληρωμένο</string> <string name="max_bitrate">Μέγιστος ρυθμός μετάδοσης δεδομένων</string> <string name="more">Περισσότερα</string> <string name="no_data">Χωρίς δεδομένα</string> <string name="no_results">Χωρίς αποτελέσματα</string> - <string name="no_scheduled_recordings">Χωρίς προγραμματισμένες καταχωρήσεις</string> + <string name="no_scheduled_recordings">Χωρίς προγραμματισμένες καταγραφές</string> <string name="no_update_available">Χωρίς διαθέσιμη ενημέρωση</string> <string name="play_from_here">Έναρξη από εδώ</string> <string name="play">Έναρξη</string> - <string name="people">Άτομα</string> + <string name="people_title">Άτομα</string> <string name="playback_speed">Ταχύτητα αναπαραγωγής</string> <string name="playback">Αναπαραγωγή</string> <string name="playlist">Λίστα αναπαραγωγής</string> @@ -117,16 +117,16 @@ <string name="profile_specific_settings">Ρυθμίσεις προφίλ χρήστη</string> <string name="queue">Ουρά</string> <string name="recap">Ανακεφαλαίωση</string> - <string name="recently_added_in">Πρόσφατα πρόσθετα σε %1$s</string> - <string name="recently_added">Πρόσφατα πρόσθετα</string> + <string name="recently_added_in">Πρόσφατες προσθήκες σε %1$s</string> + <string name="recently_added">Πρόσφατες προσθήκες</string> <string name="recently_recorded">Πρόσφατα καταγεγραμμένο</string> - <string name="recently_released">Πρόσφατα κυκλοφόρησε</string> + <string name="recently_released">Πρόσφατης κυκλοφορίας</string> <string name="recommended">Προτεινόμενο</string> - <string name="record_program">Πρόγραμμα καταγραφής</string> - <string name="record_series">Σειρά καταγραφής</string> - <string name="remove_favorite">Κατάργηση από τα αγαπημένα</string> + <string name="record_program">Καταγραφή προγράμματος</string> + <string name="record_series">Καταγραφή σειράς</string> + <string name="remove_favorite">Αναίρεση αγαπημένου</string> <string name="restart">Επανεκκίνηση</string> - <string name="resume">Περίληψη</string> + <string name="resume">Συνέχισε</string> <string name="save">Αποθήκευση</string> <string name="search">Αναζήτηση</string> <string name="searching">Αναζήτηση…</string> @@ -136,9 +136,9 @@ <string name="show_next_up_when">Εμφάνιση επόμενου</string> <string name="show">Εμφάνιση</string> <string name="shuffle">Τυχαία αναπαραγωγή</string> - <string name="skip">Αναπήδηση</string> - <string name="sort_by_date_added">Προσθήκη ημερομηνίας</string> - <string name="sort_by_date_episode_added">Προσθήκη ημερομηνίας επεισοδίου</string> + <string name="skip">Παράλειψε</string> + <string name="sort_by_date_added">Ημερομηνία Προσθήκης</string> + <string name="sort_by_date_episode_added">Ημερομηνία Προσθήκης Επεισοδίου</string> <string name="sort_by_date_played">Ημερομηνία αναπαραγωγής</string> <string name="sort_by_date_released">Ημερομηνία κυκλοφορίας</string> <string name="sort_by_name">Όνομα</string> @@ -146,17 +146,17 @@ <string name="submit">Υποβολή</string> <string name="active_recordings">Ενεργές καταγραφές</string> <string name="none">Κανένας</string> - <string name="playback_debug_info">Εμφάνιση πληροφοριών αναπαραγωγής σφαλμάτων</string> - <string name="subtitle_download_too_long">Λήψη αρχείων διαρκεί πολύ χρονο, ίσως χρειαστεί να επανεκκινήσετε την αναπαραγωγή</string> + <string name="playback_debug_info">Εμφάνιση πληροφοριών σφαλμάτων αναπαραγωγής</string> + <string name="subtitle_download_too_long">Καθυστέρηση της λήψης, ίσως απαιτείται επανεκκίνηση αναπαραγωγής</string> <string name="switch_servers">Αλλαγή διακομιστή</string> - <string name="switch_user">Αντικατάσταση</string> - <string name="top_unwatched">Κορυφαίες βαθμολογίες σε ταινίες που δεν έχω δει</string> + <string name="switch_user">Αλλαγή</string> + <string name="top_unwatched">Κορυφαίας βαθμολογίας που δεν έχουν προβληθεί</string> <string name="tv_guide">Οδηγός</string> <string name="tv_season">Σεζόν</string> <string name="tv_seasons">Περισσότερες Σεζόν</string> <string name="unknown">Άγνωστο</string> <string name="updates">Αναβαθμίσεις</string> - <string name="version">Εκδοχή</string> + <string name="version">Έκδοση</string> <string name="video_scale">Κλίμακα βίντεο</string> <string name="watch_live">Ζωντανή μετάδοση</string> <string name="media_information">Πληροφορίες μέσων</string> @@ -167,46 +167,227 @@ <string name="italic_font">Πλάγια γραμματοσειρά</string> <string name="success">Επιτυχής</string> <string name="official_rating">Γονεική αξιολόγηση</string> - <string name="runtime_sort">Χρόνος εκτέλεσης</string> - <string name="extras">Επιπρόσθετα</string> + <string name="runtime_sort">Διάρκεια</string> + <string name="extras_title">Επιπρόσθετα</string> <plurals name="other_extras"> <item quantity="one">Άλλο</item> - <item quantity="other"/> + <item quantity="other">Άλλα</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="one">Παρασκήνια</item> - <item quantity="other"/> + <item quantity="one">Παρασκήνιο</item> + <item quantity="other">Παρασκήνια</item> </plurals> <plurals name="clips"> - <item quantity="one">Αποσπάσματα</item> - <item quantity="other"/> + <item quantity="one">Απόσπασμα</item> + <item quantity="other">Αποσπάσματα</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Διαγραμμένες σκηνές</item> - <item quantity="other"/> + <item quantity="one">Διαγραμμένη σκηνή</item> + <item quantity="other">Διαγραμμένες σκηνές</item> </plurals> <plurals name="interviews"> - <item quantity="one">Συνεντεύξεις</item> - <item quantity="other"/> + <item quantity="one">Συνέντευξη</item> + <item quantity="other">Συνεντεύξεις</item> </plurals> <plurals name="scenes"> - <item quantity="one">Σκηνές</item> - <item quantity="other"/> + <item quantity="one">Σκηνή</item> + <item quantity="other">Σκηνές</item> </plurals> <plurals name="samples"> - <item quantity="one">Δείγματα</item> - <item quantity="other"/> + <item quantity="one">Δείγμα</item> + <item quantity="other">Δείγματα</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Χαρακτηριστικά</item> - <item quantity="other"/> + <item quantity="one">Προωθητικό βίντεο</item> + <item quantity="other">Προωθητικά βίντεο</item> </plurals> <string name="auto_check_for_updates">Αυτόματος έλεγχος για ενημερώσεις</string> <string name="auto_play_next_delay">Καθυστέρηση πριν την έναρξη του επόμενου</string> <string name="auto_play_next">Αυτόματη έναρξη επόμενου</string> <string name="check_for_updates">Έλεχγος για ενημερώσεις</string> <string name="combine_continue_next_summary">Ισχύει μόνο για τηλεοπτικές σειρές</string> - <string name="global_content_scale">Προεπιλεγμένη αναπαραγωγή περιεχομένου</string> + <string name="global_content_scale">Προεπιλεγμένη κλίμακα περιεχομένου</string> <string name="install_update">Εγκατάσταση ενημέρωσης</string> - <string name="installed_version">Εγκατεστημένη εκδοχή</string> + <string name="installed_version">Εγκατεστημένη έκδοση</string> + <string name="about">Πληροφορίες</string> + <string name="duration_hours_minutes_seconds">%1$dω %2$dλ %3$dδ</string> + <string name="duration_hours_minutes">%1$dω %2$dλ</string> + <string name="duration_minutes_seconds">%1$dλ %2$dδ</string> + <string name="duration_hours">%1$dω</string> + <string name="duration_minutes">%1$dλ</string> + <string name="duration_seconds">%1$dδ</string> + <string name="time_left">%1$s απέμεινε</string> + <string name="duration_hours_seconds">%1$dω %2$dδ</string> + <string name="discovered_servers">Εντοπισμένοι διακομιστές</string> + <string name="ends_at">Τελειώνει σε %1$s</string> + <string name="resolution_switching">Εναλλαγή ανάλυσης</string> + <string name="local">Τοπικό</string> + <string name="play_trailer">Αναπαραγωγή τρέιλερ</string> + <string name="no_trailers">Λείπει τρέιλερ</string> + <string name="clear_track_choices">Καθαρισμός επιλογών καναλιού</string> + <string name="enter_server_address">Δώσε την διεύθυνση διακομιστή</string> + <string name="no_servers_found">Δεν βρέθηκαν διακομιστές</string> + <string name="play_count">Μετρητής προβολών</string> + <string name="only_forced_subtitles">Μόνο αναγκαστικοί υπότιτλοι</string> + <string name="outro">Τίτλοι τέλους</string> + <string name="voice_error_audio">Σφάλμα καταγραφής ήχου</string> + <string name="visualizer_rationale">Η οπτικοποίηση του ήχου αναπαραγωγής απαιτεί άδεια καταγραφής ήχου.</string> + <string name="recordings">Καταγραφές</string> + <string name="profile_specific_settings_from_server">Ρυθμίσεις του προφίλ χρήστη στο διακομιστή</string> + <string name="subtitle_mode">Λειτουργία υποτίτλων</string> + <string name="use_user_profile">Χρήση των ρυθμίσεων του προφίλ</string> + <string name="subtitle_mode_default">Προεπιλογή</string> + <string name="subtitle_mode_smart">Έξυπνο</string> + <string name="subtitle_mode_only_forced">Μόνο αναγκαστικοί</string> + <string name="subtitle_mode_always">Πάντα</string> + <string name="subtitle_mode_none">Κανένα</string> + <string name="preferred_subtitle_language">Προτιμώμενη γλώσσα υποτίτλων</string> + <string name="preferred_audio_language">Προτιμώμενη γλώσσα ήχου</string> + <string name="override_user_profile_settings">Περισσότερες ρυθμίσεις του προφίλ χρήστη</string> + <string name="override_user_profile_settings_summary">Παράκαμψη των ρυθμίσεων του προφίλ χρήστη στον διακομιστή</string> + <string name="any_language">Οποιαδήποτε γλώσσα</string> + <string name="cinema_mode">Λειτουργία σινεμά</string> + <string name="download_and_update"><![CDATA[Λήψη και ενημέρωση]]></string> + <plurals name="movies"> + <item quantity="one">Ταινία</item> + <item quantity="other">Ταινίες</item> + </plurals> + <plurals name="people"> + <item quantity="one">Άτομο</item> + <item quantity="other">Άτομα</item> + </plurals> + <string name="search_and_download"><![CDATA[Αναζήτηση και λήψη]]></string> + <plurals name="trailers"> + <item quantity="one">Τρέιλερ</item> + <item quantity="other">Τρέιλερ</item> + </plurals> + <string name="tv_dvr_schedule">Προγραμματισμός DVR</string> + <plurals name="tv_shows"> + <item quantity="one">Σειρά</item> + <item quantity="other">Σειρές</item> + </plurals> + <string name="ui_interface">Διεπαφή</string> + <plurals name="years_old"> + <item quantity="one">%1$d έτους</item> + <item quantity="other">%1$d ετών</item> + </plurals> + <string name="play_with_transcoding">Αναπαραγωγή με μετατροπή</string> + <string name="aired_episode_order">Σειρά τηλεοπτικής προβολής</string> + <string name="one_click_pause_summary_on">Παύση/Αναπαραγωγή με το κεντρικό κουμπί του σταυρού πλοήγησης</string> + <plurals name="shorts"> + <item quantity="one">Μικρού μήκους</item> + <item quantity="other">Μικρού μήκους</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d ώρα</item> + <item quantity="other">%d ώρες</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d αντικείμενο</item> + <item quantity="other">%d αντικείμενα</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d δευτερόλεπτο</item> + <item quantity="other">%d δευτερόλεπτα</item> + </plurals> + <string name="ac3_supported">Η συσκευή υποστηρίζει AC3/Dolby Digital</string> + <string name="advanced_ui">Προηγμένη διεπαφή</string> + <string name="combine_continue_next"><![CDATA[Συγχώνευση Συνέχεια προβολής και Επόμενα]]></string> + <string name="max_days_next_up">Ανώτατο όριο ημερών στα Επόμενα</string> + <string name="next_up_playback_end">Στο τέλος της αναπαραγωγής</string> + <string name="next_up_outro">Κατά τη διάρκεια των τίτλων τέλους</string> + <string name="remove_next_up">Αφαίρεση σειράς από τα Επόμενα</string> + <string name="view_removed_next_up">Εμφάνιση σειρών που αφαιρέθηκαν από τα Επόμενα</string> + <string name="next_up_never">Ποτέ</string> + <string name="continue_watching_click_behavior">Συμπεριφορά επιλογής του Συνέχεια προβολής/Επόμενο</string> + <string name="remove_continue_watching">Αφαίρεση από συνέχεια προβολής</string> + <string name="continue_watching_click_summary_on">Αναπαραγωγή κατά την επιλογή</string> + <string name="continue_watching_click_summary_off">Προβολή λεπτομέρειον κατά την επιλογή</string> + <string name="direct_play_ass">Χρήση libass για ASS υπότιτλους</string> + <string name="direct_play_pgs">Απευθείας αναπαραγωγή PGS υπότιτλων</string> + <string name="downmix_stereo">Πάντα υποβιβασμός σε στέρεο</string> + <string name="ffmpeg_extension_pref">Χρήση αποκωδικοποιητή FFmpeg</string> + <string name="max_homepage_items">Ανώτατο όριο αντικειμένων στις γραμμές της αρχικής</string> + <string name="nav_drawer_pins_summary">Διάλεξε τα προεπιλεγμένα αντικείμενα για εμφάνιση, θα γίνει απόκρυψη των υπόλοιπων</string> + <string name="remember_selected_tab">Απομνημόνευση επιλεγμένων καρτελών</string> + <string name="settings">Ρυθμίσεις</string> + <string name="discover">Ανακάλυψε</string> + <string name="request">Αίτημα</string> + <string name="pending">Εκκρεμί</string> + <string name="password">Κωδικός</string> + <string name="username">Όνομα χρήστη</string> + <string name="trending">Τάσεις</string> + <string name="red">Κόκκινο</string> + <string name="green">Πράσινο</string> + <string name="blue">Μπλε</string> + <string name="blur">Θόλωση</string> + <string name="height">Ύψος</string> + <string name="content_scale_fit">Προσαρμογή</string> + <string name="content_scale_crop">Περικοπή</string> + <string name="content_scale_fill">Γέμισμα</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="black">Μαύρο</string> + <string name="skip_ignore">Αγνόηση</string> + <string name="white">Άσπρο</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="duration">Διάρκεια</string> + <string name="screensaver">Προστασία οθόνης</string> + <string name="photos">Φωτογραφίες</string> + <string name="actor">Ηθοποιός</string> + <string name="composer">Συνθέτης</string> + <string name="writer">Συγγραφέας</string> + <string name="producer">Παραγωγός</string> + <string name="conductor">Μαέστρος</string> + <string name="lyricist">Στιχουργός</string> + <string name="arranger">Ενορχηστρωτής</string> + <string name="mixer">Υπεύθυνος μίξης</string> + <string name="creator">Δημιουργός</string> + <string name="artist">Καλλιτέχνης</string> + <string name="albums">Άλμπουμ</string> + <string name="artists">Καλλιτέχνες</string> + <string name="songs">Τραγούδια</string> + <string name="album">Άλμπουμ</string> + <string name="lyrics">Στίχοι</string> + <string name="continue_string">Συνεχεία</string> + <string name="transcoding">Μετατροπή</string> + <string name="reset">Επαναφορά</string> + <string name="played">Παίχτηκε</string> + <string name="filter">Φίλτρο</string> + <string name="year">Έτος</string> + <string name="decade">Δεκαετία</string> + <string name="remove">Αφαίρεση</string> + <string name="subtitle_margin">Περιθώριο</string> + <string name="incorrect">Λανθασμένο</string> + <string name="columns">Στήλες</string> + <string name="spacing">Διάστιχο</string> + <string name="automatic">Αυτόματα</string> + <string name="login">Σύνδεση</string> + <string name="general">Γενικά</string> + <string name="title">Τίτλος</string> + <string name="level">Επίπεδο</string> + <string name="resolution">Ανάλυση</string> + <string name="anamorphic">Αναμορφικό</string> + <string name="interlaced">Πεπλεγμένο</string> + <string name="framerate">Ρυθμός ανανέωσης</string> + <string name="live_tv_repeat">Επανάληψη</string> + <string name="retry">Επανάλαβε</string> + <string name="brightness">Φωτεινότητα</string> + <string name="contrast">Αντίθεση</string> + <string name="saturation">Κορεσμός</string> + <string name="hue">Απόχρωση</string> + <string name="subtitle_delay">Καθυστέρηση υποτίτλων</string> + <string name="discover_movies">Ανακάλυψε ταινίες</string> + <string name="discover_tv">Ανακάλυψε σειρές</string> </resources> diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 00000000..6f57641a --- /dev/null +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="add_favorite">Favourite</string> + <string name="favorites">Favourites</string> + <string name="license_info">Licence information</string> + <string name="record_program">Record Programme</string> + <string name="remove_favorite">Unfavourite</string> + <string name="voice_error_no_match">No speech recognised</string> + <string name="one_click_pause_summary_on">Press D-Pad centre to pause/play</string> + <string name="italic_font">Italicise font</string> + <string name="favorite_items_title">Favourite %s</string> + <string name="nav_drawer_pins">Customise Navigation Drawer Items</string> + <string name="skip_commercials_behavior">Skip commercials behaviour</string> + <string name="skip_intro_behavior">Skip intro behaviour</string> + <string name="skip_outro_behavior">Skip outro behaviour</string> + <string name="skip_previews_behavior">Skip previews behaviour</string> + <string name="skip_recap_behavior">Skip recap behaviour</string> + <string name="font_color">Font colour</string> + <string name="edge_color">Edge colour</string> + <string name="background_color">Background colour</string> + <string name="press_enter_to_confirm">Press centre to confirm</string> + <string name="color_space">Colour space</string> + <string name="color_transfer">Colour transfer</string> + <string name="color_primaries">Colour primaries</string> + <string name="favorite_channels_at_beginning">Show favourite channels first</string> + <string name="color_code_programs">Colour-code programmes</string> + <string name="quick_connect_summary">Authorise another device to log in to your account</string> + <string name="quick_connect_success">Device authorised successfully</string> + <string name="contrast">Contrast</string> + <string name="customize_home">Customise home page</string> + <string name="light_gray">Light grey</string> + <string name="dark_gray">Dark grey</string> + <string name="backdrop_style_dynamic">Image with dynamic colour</string> + <string name="show_visualizer">Show visualiser</string> + <string name="visualizer_rationale">Visualising the currently playing audio requires permission to record audio.</string> + <string name="skip_behavior">Skip behaviour</string> + <string name="display_toggles_summary">Customise which to display</string> + <string name="continue_watching_click_behavior">Continue watching/Next up click behaviour</string> + <string name="about">About</string> + <string name="active_recordings">Active Recordings</string> + <string name="add_server">Add Server</string> + <string name="add_user">Add User</string> + <string name="audio">Audio</string> + <string name="birthplace">Birthplace</string> + <string name="bitrate">Bitrate</string> + <string name="born">Born</string> + <string name="cancel_recording">Cancel Recording</string> + <string name="cancel_series_recording">Cancel Series Recording</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="cancel">Cancel</string> + <string name="chapters">Chapters</string> + <string name="choose_stream">Choose %1$s</string> +</resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4e6ba9e6..c70eeeba 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -36,7 +36,7 @@ <string name="no_results">Sin resultados</string> <string name="none">Ninguno</string> <string name="outro">Créditos finales</string> - <string name="people">Personas</string> + <string name="people_title">Personas</string> <string name="play">Reproducir</string> <string name="playback">Reproducción</string> <string name="playlist">Lista de reproducción</string> @@ -63,18 +63,21 @@ <string name="suggestions">Sugerencias</string> <string name="switch_servers">Cambiar servidor</string> <string name="switch_user">Cambiar</string> - <string name="trailer">Tráiler</string> <plurals name="trailers"> <item quantity="one">Tráiler</item> - <item quantity="many">Tráilers</item> - <item quantity="other"/> + <item quantity="many">Tráileres</item> + <item quantity="other">Tráileres</item> </plurals> - <string name="tv_shows">Series</string> + <string name="tv_shows_title">Series</string> <string name="ui_interface">Interfaz</string> <string name="version">Versión</string> <string name="video">Vídeo</string> <string name="videos">Vídeos</string> - <string name="years_old">%1$d años</string> + <plurals name="years_old"> + <item quantity="one">%1$d año</item> + <item quantity="many">%1$d años</item> + <item quantity="other">%1$d años</item> + </plurals> <string name="play_with_transcoding">Reproducir con transcodificación</string> <string name="show_clock">Mostrar reloj</string> <string name="create_playlist">Crear nueva lista de reproducción</string> @@ -83,11 +86,11 @@ <string name="one_click_pause_summary_on">Pulsa el botón central de la cruceta para reproducir/pausar</string> <string name="success">Éxito</string> <string name="runtime_sort">Duración</string> - <string name="extras">Extras</string> + <string name="extras_title">Extras</string> <plurals name="other_extras"> <item quantity="one">Otro</item> <item quantity="many">Otros</item> - <item quantity="other"/> + <item quantity="other">Otros</item> </plurals> <string name="advanced_settings">Configuración avanzada</string> <string name="advanced_ui">Interfaz avanzada</string> @@ -134,7 +137,7 @@ <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="movies_title">Películas</string> <string name="next_up">Siguiente</string> <string name="no_data">Sin datos</string> <string name="no_scheduled_recordings">Sin grabaciones programadas</string> @@ -164,19 +167,19 @@ <string name="aired_episode_order">Orden de emisión</string> <string name="italic_font">Cursiva</string> <plurals name="behind_the_scenes"> - <item quantity="one">Detrás de cámaras</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Detrás de las cámaras</item> + <item quantity="many">Detrás de las cámaras</item> + <item quantity="other">Detrás de las cámaras</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Canción principal</item> - <item quantity="many">Canciones principales</item> - <item quantity="other"/> + <item quantity="one">Tema musical</item> + <item quantity="many">Temas musicales</item> + <item quantity="other">Temas musicales</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Vídeos temáticos</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Vídeo temático</item> + <item quantity="many">Vídeos temáticos</item> + <item quantity="other">Vídeos temáticos</item> </plurals> <plurals name="clips"> <item quantity="one">Clip</item> @@ -186,32 +189,32 @@ <plurals name="deleted_scenes"> <item quantity="one">Escena eliminada</item> <item quantity="many">Escenas eliminadas</item> - <item quantity="other"/> + <item quantity="other">Escenas eliminadas</item> </plurals> <plurals name="interviews"> <item quantity="one">Entrevista</item> <item quantity="many">Entrevistas</item> - <item quantity="other"/> + <item quantity="other">Entrevistas</item> </plurals> <plurals name="scenes"> <item quantity="one">Escena</item> <item quantity="many">Escenas</item> - <item quantity="other"/> + <item quantity="other">Escenas</item> </plurals> <plurals name="samples"> <item quantity="one">Muestra</item> <item quantity="many">Muestras</item> - <item quantity="other"/> + <item quantity="other">Muestras</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Mini-documentales</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Reportaje</item> + <item quantity="many">Reportajes</item> + <item quantity="other">Reportajes</item> </plurals> <plurals name="shorts"> - <item quantity="one">CortoCortos</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Corto</item> + <item quantity="many">Cortos</item> + <item quantity="other">Cortos</item> </plurals> <plurals name="downloads"> <item quantity="one">%s descarga</item> @@ -480,7 +483,7 @@ <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="favorite_items_title">Favoritos%s</string> <string name="content_scale_fit">Ajustar</string> <string name="content_scale_crop">Recortar</string> <string name="content_scale_fill">Rellenar</string> @@ -641,4 +644,98 @@ <string name="view_options_type_list_compact">Lista compacta</string> <string name="sort">Ordenar</string> <string name="split_into_separate_rows">Separar en filas independientes</string> + <string name="other_extras_title">Otro</string> + <string name="behind_the_scenes_title">Detrás de las cámaras</string> + <string name="theme_songs_title">Temas musicales</string> + <string name="theme_videos_title">Vídeos temáticos</string> + <string name="clips_title">Fragmentos de vídeo</string> + <string name="deleted_scenes_title">Escenas eliminadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Escenas</string> + <string name="samples_title">Muestras</string> + <string name="featurettes_title">Reportajes</string> + <string name="shorts_title">Cortos</string> + <plurals name="movies"> + <item quantity="one">Película</item> + <item quantity="many">Películas</item> + <item quantity="other">Películas</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="many">Series</item> + <item quantity="other">Series</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persona</item> + <item quantity="many">Personas</item> + <item quantity="other">Personas</item> + </plurals> + <string name="trailers_title">Tráileres</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar audio no estéreo a AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para audio multicanal</string> + <string name="genre_movies">%1$s Películas</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Configuración de calidad</string> + <string name="is_default">Predeterminado</string> + <string name="root_folder">Carpeta principal</string> + <string name="popularity">Popularidad</string> + <string name="production_status">Estado</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Ajustes experimentales</string> + <string name="confirm_enable_experimental_title">¿Habilitar ajustes experimentales?</string> + <string name="confirm_enable_experimental_body">¡Los ajustes experimentales pueden ser inestables!\n\n Pueden ser cambiados o eliminados en cualquier lanzamiento.</string> + <string name="video_tunneling">Tunelización de video</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">No disponible</string> + <string name="profile_specific_settings_from_server">Configuración de usuario desde el servidor</string> + <string name="subtitle_mode">Modo de subtítulos</string> + <string name="use_user_profile">Usar tu configuración de usuario</string> + <string name="subtitle_mode_default">Predeterminado</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Solo forzados</string> + <string name="subtitle_mode_always">Siempre</string> + <string name="subtitle_mode_none">Ninguno</string> + <string name="preferred_subtitle_language">Idioma de subtítulos preferido</string> + <string name="preferred_audio_language">Idioma de audio preferido</string> + <string name="override_user_profile_settings">Más ajustes de usuario</string> + <string name="override_user_profile_settings_summary">Sobreescribir los ajustes de usuario del servidor</string> + <string name="any_language">Cualquier idioma</string> + <string name="d_pad_seek_mode_title">Modo de búsqueda con mando</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="hash_matches">Coincidencias de valores hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de televisión</string> + <string name="recordings">Grabaciones</string> + <string name="folders_title">Carpetas</string> + <string name="user_views">Vistas de usuario</string> + <string name="books">Libros</string> + <string name="audio_books">Audiolibros</string> + <string name="dpad_seek_mode_minimal">Mínima</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progreso pequeña en la parte inferior de la pantalla (predeterminado)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progreso con vista previa de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sin barra de progreso</string> + <string name="brown">Marrón</string> + <string name="server_version_not_supported">Versión de servidor no soportada</string> + <string name="appears_on">Aparece en</string> + <string name="disable_audio_offload">Desactivar el procesamiento de audio por hardware</string> + <string name="disable_audio_offload_summary">Desactivar el procesamiento de audio por hardware puede solucionar los bloqueos de reproducción en algunos dispositivos</string> + <string name="dim_after_delay">Atenuar la pantalla tras un tiempo de inactividad</string> + <string name="dim_percentage">Porcentaje de atenuación</string> + <string name="dim_screen">Atenuar pantalla</string> + <string name="photo_albums">Álbumes de fotos</string> + <string name="no_favorites_found">¡No se encontraron favoritos!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">T%1$d E%2$d</string> + <string name="season_episode_number_range">T%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">T%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">T%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Temporada %1$d</string> </resources> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 339ec1c6..ffbec2a7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -62,7 +62,7 @@ <string name="max_bitrate">Suurim bitikiirus</string> <string name="more_like_this">Veel sarnaseid</string> <string name="more">Veel</string> - <string name="movies">Filmid</string> + <string name="movies_title">Filmid</string> <string name="name">Nimi</string> <string name="next_up">Järgmisena esitamisel</string> <string name="no_data">Andmed puuduvad</string> @@ -72,7 +72,7 @@ <string name="none">Määramata</string> <string name="outro">Väljajuhatus</string> <string name="path">Rada</string> - <string name="people">Inimesed</string> + <string name="people_title">Inimesed</string> <string name="play_count">Esitusi</string> <string name="play_from_here">Esita siit kohast</string> <string name="play">Esita</string> @@ -123,7 +123,7 @@ <string name="success">Valmis</string> <string name="official_rating">Vanemlik hinnang</string> <string name="runtime_sort">Kestus</string> - <string name="extras">Lisad</string> + <string name="extras_title">Lisad</string> <plurals name="other_extras"> <item quantity="one">Muu</item> <item quantity="other">Muud</item> @@ -157,8 +157,8 @@ <item quantity="other">Stseenid</item> </plurals> <plurals name="samples"> - <item quantity="one">Näide</item> - <item quantity="other">Näited</item> + <item quantity="one">Näidislõik</item> + <item quantity="other">Näidislõigud</item> </plurals> <plurals name="featurettes"> <item quantity="one">Keskmise pikkusega film</item> @@ -220,19 +220,21 @@ <string name="record_program">Salvesta programm</string> <string name="record_series">Salvesta telesari</string> <string name="top_unwatched">Parima hinnanguga vaatamata sisu</string> - <string name="trailer">Treiler</string> <plurals name="trailers"> - <item quantity="one">Treilerid</item> - <item quantity="other"/> + <item quantity="one">Treiler</item> + <item quantity="other">Treilerid</item> </plurals> <string name="tv_dvr_schedule">DVR-i kava</string> <string name="tv_guide">Telekava</string> - <string name="tv_shows">Telesaated</string> + <string name="tv_shows_title">Telesaated</string> <string name="video_scale">Video skaleerimine</string> <string name="video">Video</string> <string name="videos">Videod</string> <string name="watch_live">Vaata otse-eetris</string> - <string name="years_old">%1$d aasta(t) vana</string> + <plurals name="years_old"> + <item quantity="one">%1$d aasta vana</item> + <item quantity="other">%1$d aastat vana</item> + </plurals> <string name="play_with_transcoding">Esita koos teisendamisega</string> <string name="show_clock">Näita kella</string> <string name="aired_episode_order">Eetris olnud osade järjestus</string> @@ -463,7 +465,7 @@ <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> + <string name="favorite_items_title">Märgi %s lemmikuks</string> <plurals name="minutes"> <item quantity="one">%s minut</item> <item quantity="other">%s minutit</item> @@ -623,4 +625,96 @@ <string name="view_options_type_list_compact">Kompaktne loend</string> <string name="sort">Järjesta</string> <string name="split_into_separate_rows">Eralda ridadena</string> + <string name="trailers_title">Treilerid</string> + <string name="other_extras_title">Muud</string> + <string name="behind_the_scenes_title">Kaadri tagaKaadri taga</string> + <string name="theme_songs_title">Tunnuslood</string> + <string name="theme_videos_title">Tunnusvideod</string> + <string name="clips_title">Klipid</string> + <string name="deleted_scenes_title">Väljajäetud lõigud</string> + <string name="interviews_title">Intervjuud</string> + <string name="scenes_title">Stseenid</string> + <string name="samples_title">Näidislõigud</string> + <string name="featurettes_title">Keskmise pikkusega filmid</string> + <string name="shorts_title">Lühifilmid</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmid</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Telesaade</item> + <item quantity="other">Telesaated</item> + </plurals> + <plurals name="people"> + <item quantity="one">Isik</item> + <item quantity="other">Inimesed</item> + </plurals> + <string name="genre_movies">%1$s filmi</string> + <string name="genre_tv_shows">%1$s telesaadet</string> + <string name="quality_profile">Kvaliteedi profiil</string> + <string name="is_default">Vaikimisi</string> + <string name="root_folder">Juurkaust</string> + <string name="popularity">Populaarsus</string> + <string name="production_status">Tootmise olek</string> + <string name="different_for_hdr">Kasuta HDR-i puhul muud stiili</string> + <string name="experimental_settings">Katselised seadistused</string> + <string name="confirm_enable_experimental_title">Kas võtad katselised seadistused kasutusele?</string> + <string name="confirm_enable_experimental_body">Katselised seadistused ei pruugi olla päris töökindlad!\n\n Lisaks võib arendaja neid omal äranägemisel muuta või sootuks eemaldada.</string> + <string name="video_tunneling">Video tunneldus</string> + <string name="duration_hours_minutes_seconds">%1$d t %2$d min %3$d sek</string> + <string name="duration_hours_minutes">%1$d t %2$d min</string> + <string name="duration_minutes_seconds">%1$d min %2$d sek</string> + <string name="duration_hours">%1$d t</string> + <string name="duration_minutes">%1$d min</string> + <string name="duration_seconds">%1$d sek</string> + <string name="time_left">jäänud %1$s</string> + <string name="blocklisted">Keeluloendis</string> + <string name="unavailable">Pole saadaval</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Kodeeri mittestereoheli AC3/Dolby Digital vormingusse]]></string> + <string name="prefer_ac3_for_surround">Kasuta ruumilise heli jaoks AC3 koodekit</string> + <string name="profile_specific_settings_from_server">Kasutajaprofiili seadistus serverist</string> + <string name="subtitle_mode">Subtiitrite režiim</string> + <string name="use_user_profile">Kasuta oma profiili seadistust</string> + <string name="subtitle_mode_default">Vaikimisi</string> + <string name="subtitle_mode_smart">Nutikas</string> + <string name="subtitle_mode_only_forced">Ainult sundkorras</string> + <string name="subtitle_mode_always">Alati</string> + <string name="subtitle_mode_none">Määramata</string> + <string name="preferred_subtitle_language">Subtiitrite eelistatud keel</string> + <string name="preferred_audio_language">Heliriba eelistatud keel</string> + <string name="override_user_profile_settings">Veel kasutajaprofiili seadistusi</string> + <string name="override_user_profile_settings_summary">Sürjuta serveris leiduvad kasutajaprofiili seadistused</string> + <string name="any_language">Iga keel</string> + <string name="brown">Pruun</string> + <string name="programs">Teleprogrammid</string> + <string name="recordings">Salvestused</string> + <string name="folders_title">Kaustad</string> + <string name="user_views">Kasutajate vaatamisi</string> + <string name="books">Raamatud</string> + <string name="audio_books">Heliraamatud</string> + <string name="server_version_not_supported">Serveri versioon pole toetatud</string> + <string name="dpad_seek_mode_minimal">Minimaalne</string> + <string name="d_pad_seek_mode_title">D-Pad\'i kerimisriba tööpõhimõte</string> + <string name="dpad_seek_mode_minimal_summary">Väike kerimisriba ekraani allosas (vaikimisi variant)</string> + <string name="dpad_seek_mode_trickplay">Trikitamine</string> + <string name="dpad_seek_mode_trickplay_summary">Kerimisriba trikitamise eelvaatepiltidega</string> + <string name="dpad_seek_mode_none_summary">Kerimisriba pole</string> + <string name="hash_matches">Räsivasted</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="appears_on">On osalenud siin:</string> + <string name="disable_audio_offload">Lülita graafikaprotsessori kasutamine heli töötluseks välja</string> + <string name="disable_audio_offload_summary">Kui lülita graafikaprotsessori kasutamise heli töötluseks välja, siis see võib mõnede seadmete puhul parandada taasesituse hangumise vead</string> + <string name="dim_after_delay">Hämarda ekraani peale viivitust</string> + <string name="dim_percentage">Hämardamise protsent</string> + <string name="dim_screen">Hämarda ekraani</string> + <string name="photo_albums">Fotoalbumid</string> + <string name="no_favorites_found">Ühtegi lemmikut ei leidu!</string> + <string name="duration_hours_seconds">%1$d t %2$d sek</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">%1$d. hooaeg</string> + <string name="remove_from_playlist">Eemalda esitusloendist</string> </resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d6343b9e..4d7506ed 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -48,7 +48,7 @@ <string name="mark_unwatched">Marquer comme non vu</string> <string name="mark_watched">Marquer comme vu</string> <string name="max_bitrate">Débit maximal</string> - <string name="movies">Films</string> + <string name="movies_title">Films</string> <string name="name">Nom</string> <string name="next_up">À suivre</string> <string name="no_data">Aucune donnée</string> @@ -58,7 +58,7 @@ <string name="none">Aucun</string> <string name="outro">Générique de fin</string> <string name="path">Chemin</string> - <string name="people">Personnes</string> + <string name="people_title">Personnes</string> <string name="play_from_here">Lire à partir d’ici</string> <string name="play">Lire</string> <string name="playback_speed">Vitesse de lecture</string> @@ -98,15 +98,14 @@ <string name="suggestions">Suggestions</string> <string name="switch_servers">Changer de serveur</string> <string name="switch_user">Changer</string> - <string name="trailer">Bande-annonce</string> <plurals name="trailers"> - <item quantity="one">Bandes-annonces</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Bandes-annonce</item> + <item quantity="many">Bandes-annonces</item> + <item quantity="other">Bandes-annonces</item> </plurals> <string name="tv_season">Saison</string> <string name="tv_seasons">Saisons</string> - <string name="tv_shows">Séries TV</string> + <string name="tv_shows_title">Séries TV</string> <string name="unknown">Inconnu</string> <string name="updates">Mises à jour</string> <string name="version">Version</string> @@ -126,34 +125,34 @@ <string name="success">Succès</string> <string name="runtime_sort">Durée</string> <plurals name="other_extras"> - <item quantity="one">Autres</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Autre</item> + <item quantity="many">Autres</item> + <item quantity="other">Autres</item> </plurals> <plurals name="clips"> - <item quantity="one">Extraits</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Extrait</item> + <item quantity="many">Extraits</item> + <item quantity="other">Extraits</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Scènes supprimées</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Scène supprimé</item> + <item quantity="other">Scènes supprimées</item> </plurals> <plurals name="interviews"> <item quantity="one">Interviews</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Interview</item> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scènes</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scène</item> + <item quantity="many">Scènes</item> + <item quantity="other">Scènes</item> </plurals> <plurals name="samples"> - <item quantity="one">Échantillons</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Échantillon</item> + <item quantity="many">Échantillons</item> + <item quantity="other">Échantillons</item> </plurals> <string name="ac3_supported">Appareil compatible AC3/Dolby Digital</string> <string name="advanced_settings">Paramètres avancés</string> @@ -179,14 +178,18 @@ <string name="tv_guide">Guide</string> <string name="ui_interface">Interface</string> <string name="video_scale">Échelle de la vidéo</string> - <string name="years_old">%1$d ans</string> + <plurals name="years_old"> + <item quantity="one">%1$d an</item> + <item quantity="many">%1$d ans</item> + <item quantity="other">%1$d ans</item> + </plurals> <string name="italic_font">Mettre la police en italique</string> <string name="official_rating">Classification parentale</string> - <string name="extras">Bonus</string> + <string name="extras_title">Bonus</string> <plurals name="behind_the_scenes"> <item quantity="one">Dans les coulisses</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Dans les coulisses</item> + <item quantity="other">Dans les coulisses</item> </plurals> <string name="auto_play_next_delay">Délai avant de lire \"À suivre\"</string> <string name="auto_play_next">Lecture automatique de \"À suivre\"</string> @@ -248,9 +251,9 @@ <string name="subtitle_download_too_long">Le téléchargement prend du temps, vous devrez peut-être redémarrer la lecture</string> <string name="top_unwatched">Les mieux notés non vus</string> <plurals name="shorts"> - <item quantity="one">Courts-métrages</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Court-métrages</item> + <item quantity="many">Courts-métrages</item> + <item quantity="other">Courts-métrages</item> </plurals> <plurals name="hours"> <item quantity="one">%d heure</item> @@ -281,13 +284,13 @@ <string name="skip_recap_behavior">Comportement de saut du récapitulatif</string> <plurals name="theme_songs"> <item quantity="one">Chanson à thème</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Chansons à thème</item> + <item quantity="other">Chanson à thème</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Vidéos à thème</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Vidéos à thème</item> + <item quantity="other">Vidéos à thème</item> </plurals> <string name="combine_continue_next"><![CDATA[Combiner Continuer à regarder & À suivre]]></string> <string name="download_and_update"><![CDATA[Télécharger & Mettre à jour]]></string> @@ -298,6 +301,7 @@ <plurals name="featurettes"> <item quantity="one">Bonus</item> <item quantity="many">Bonus</item> + <item quantity="other">Bonus</item> </plurals> <string name="dolby_vision">Dolby Vision</string> <string name="dolby_atmos">Dolby Atmos</string> @@ -479,7 +483,7 @@ <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> + <string name="favorite_items_title">Favori %s</string> <plurals name="minutes"> <item quantity="one">%s minute</item> <item quantity="many">%s minutes</item> @@ -638,4 +642,86 @@ <string name="continue_watching_click_summary_off">Afficher les détails en cliquant</string> <string name="stars">Étoiles</string> <string name="tomatoes">Tomates</string> + <string name="sort">Trier</string> + <string name="split_into_separate_rows">Diviser en lignes distinctes</string> + <string name="trailers_title">Bandes-annonces</string> + <string name="other_extras_title">Autre</string> + <string name="behind_the_scenes_title">Coulisses</string> + <string name="theme_songs_title">Musiques de générique</string> + <string name="theme_videos_title">Vidéos thématiques</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Scènes coupées</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scènes</string> + <string name="samples_title">Extraits</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Courts-métrages</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="many">Films</item> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Série télévisée</item> + <item quantity="many">Séries télévisées</item> + <item quantity="other">Série télévisée</item> + </plurals> + <plurals name="people"> + <item quantity="one">Acteur</item> + <item quantity="many">Acteurs</item> + <item quantity="other">Acteur</item> + </plurals> + <string name="genre_movies">%1$s Films</string> + <string name="genre_tv_shows">%1$s Séries télévisées</string> + <string name="quality_profile">Profil de qualité</string> + <string name="is_default">Par défaut</string> + <string name="root_folder">Dossier racine</string> + <string name="popularity">Popularité</string> + <string name="production_status">État de la production</string> + <string name="different_for_hdr">Utiliser un style différent pour le HDR</string> + <string name="experimental_settings">Conditions expérimentales</string> + <string name="confirm_enable_experimental_title">Activer les paramètres expérimentaux ?</string> + <string name="confirm_enable_experimental_body">Les paramètres expérimentaux peuvent être instables !\n\nIls sont susceptibles d\'être supprimés ou modifiés dans n\'importe quelle version.</string> + <string name="video_tunneling">Tunneling vidéo</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s restant</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcoder l\'audio non stéréo vers AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilisez AC3 pour l\'audio surround</string> + <string name="blocklisted">sur liste noire</string> + <string name="unavailable">Indisponible</string> + <string name="subtitle_mode_only_forced">forcés uniquement</string> + <string name="subtitle_mode_none">Aucun</string> + <string name="preferred_subtitle_language">Langue de sous-titre préférée</string> + <string name="preferred_audio_language">Langue audio préférée</string> + <string name="profile_specific_settings_from_server">Paramètres du profil utilisateur à partir du serveur</string> + <string name="subtitle_mode">Mode sous-titres</string> + <string name="use_user_profile">Utiliser vos paramètres</string> + <string name="subtitle_mode_default">Par défaut</string> + <string name="subtitle_mode_smart">Intelligent</string> + <string name="subtitle_mode_always">Toujours</string> + <string name="override_user_profile_settings">Autres paramètres du profil utilisateur</string> + <string name="override_user_profile_settings_summary">Remplacer les paramètres de votre profil utilisateur à partir du serveur</string> + <string name="any_language">N\'importe quelle langue</string> + <string name="brown">Marron</string> + <string name="d_pad_seek_mode_title">Mode « barre de défilement » du D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimum</string> + <string name="dpad_seek_mode_trickplay">fonctionnalités de lecture avancées</string> + <string name="dpad_seek_mode_minimal_summary">Petite barre de recherche située en bas de l\'écran (par défaut)</string> + <string name="dpad_seek_mode_trickplay_summary">Barre de recherche avec images d\'aperçu des vidéos « trickplay »</string> + <string name="dpad_seek_mode_none_summary">Pas de barre de progression</string> + <string name="hash_matches">Correspondances de hachage</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Émissions de télévision</string> + <string name="recordings">Enregistrements</string> + <string name="folders_title">Dossiers</string> + <string name="user_views">Avis des utilisateurs</string> + <string name="books">Livres</string> + <string name="audio_books">Livre audio</string> + <string name="server_version_not_supported">Version du serveur non prise en charge</string> + <string name="appears_on">Apparaît dans</string> </resources> diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml new file mode 100644 index 00000000..b42d0683 --- /dev/null +++ b/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,741 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">O aplikaciji</string> + <string name="active_recordings">Aktivna snimanja</string> + <string name="add_favorite">Favorit</string> + <string name="add_server">Dodaj poslužitelj</string> + <string name="add_user">Dodaj korisnika</string> + <string name="audio">Zvuk</string> + <string name="birthplace">Mjesto rođenja</string> + <string name="born">Rođenje</string> + <string name="cancel_recording">Otkaži snimanje</string> + <string name="cancel_series_recording">Otkaži snimanje serije</string> + <string name="cancel">Odustani</string> + <string name="chapters">Poglavlja</string> + <string name="choose_stream">Odaberi %1$s</string> + <string name="clear_image_cache">Očisti predmemoriju slika</string> + <string name="collection">Kolekcija</string> + <string name="collections">Kolekcije</string> + <string name="commercial">Reklama</string> + <string name="community_rating">Ocjena zajednice</string> + <string name="compose_error_message_title">Došlo je do greške! Pritisnite gumb za slanje zapisa na poslužitelj.</string> + <string name="confirm">Potvrdi</string> + <string name="continue_watching">Nastavi gledanje</string> + <string name="critic_rating">Ocjena kritike</string> + <string name="decimal_seconds">%.2f sekundi</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="default_track">Zadano</string> + <string name="delete">Izbriši</string> + <string name="died">Smrt</string> + <string name="directed_by">Režija %1$s</string> + <string name="director">Redatelj</string> + <string name="disabled">Onemogućeno</string> + <string name="discovered_servers">Pronađeni poslužitelji</string> + <string name="download_and_update"><![CDATA[Preuzmi i ažuriraj]]></string> + <string name="downloading">Preuzimanje…</string> + <string name="enabled">Omogućeno</string> + <string name="ends_at">Završava u %1$s</string> + <string name="enter_server_url">Unesite IP ili URL poslužitelja</string> + <string name="enter_server_address">Unesite adresu poslužitelja</string> + <string name="episodes">Epizode</string> + <string name="error_loading_collection">Greška pri učitavanju kolekcije %1$s</string> + <string name="external_track">Vanjski</string> + <string name="favorites">Favoriti</string> + <string name="file_size">Veličina</string> + <string name="forced_track">Prisilni</string> + <string name="genres">Žanrovi</string> + <string name="go_to_series">Idi na seriju</string> + <string name="go_to">Idi na</string> + <string name="hide_controller_timeout">Sakrij kontrole reprodukcije</string> + <string name="hide_debug_info">Sakrij informacije za otklanjanje grešaka</string> + <string name="hide">Sakrij</string> + <string name="home">Početna</string> + <string name="immediate">Odmah</string> + <string name="intro">Uvod</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Biblioteka</string> + <string name="license_info">Informacije o licenci</string> + <string name="live_tv">Live TV</string> + <string name="loading">Učitavanje…</string> + <string name="mark_entire_series_as_played">Označiti cijelu seriju kao pogledanu?</string> + <string name="mark_entire_series_as_unplayed">Označiti cijelu seriju kao nepogledanu?</string> + <string name="mark_unwatched">Označi kao nepogledano</string> + <string name="mark_watched">Označi kao pogledano</string> + <string name="max_bitrate">Maks. bitrate</string> + <string name="more_like_this">Slično ovome</string> + <string name="more">Više</string> + <string name="movies_title">Filmovi</string> + <string name="name">Ime</string> + <string name="next_up">Slijedi</string> + <string name="no_data">Nema podataka</string> + <string name="no_results">Nema rezultata</string> + <string name="no_servers_found">Nije pronađen nijedan poslužitelj</string> + <string name="no_scheduled_recordings">Nema zakazanih snimanja</string> + <string name="no_update_available">Nema dostupnih ažuriranja</string> + <string name="bitrate">Bitrate</string> + <string name="none">Nijedan</string> + <string name="only_forced_subtitles">Samo prisilni titlovi</string> + <string name="outro">Odjava</string> + <string name="path">Putanja</string> + <string name="people_title">Osobe</string> + <string name="play_count">Broj reprodukcija</string> + <string name="play_from_here">Reproduciraj odavde</string> + <string name="play">Reproduciraj</string> + <string name="playback_debug_info">Prikaži informacije za otklanjanje grešaka reprodukcije</string> + <string name="playback_speed">Brzina reprodukcije</string> + <string name="playback">Reprodukcija</string> + <string name="playlist">Popis za reprodukciju</string> + <string name="playlists">Popisi za reprodukciju</string> + <string name="preview">Pretpregled</string> + <string name="profile_specific_settings">Postavke korisničkog profila</string> + <string name="profile_specific_settings_from_server">Postavke korisničkog profila s poslužitelja</string> + <string name="queue">Red čekanja</string> + <string name="recap">Sažetak</string> + <string name="recently_added_in">Nedavno dodano u %1$s</string> + <string name="recently_added">Nedavno dodano</string> + <string name="recently_recorded">Nedavno snimljeno</string> + <string name="recently_released">Nedavno objavljeno</string> + <string name="recommended">Preporučeno</string> + <string name="record_program">Snimi program</string> + <string name="record_series">Snimi seriju</string> + <string name="remove_favorite">Ukloni iz favorita</string> + <string name="restart">Ponovno pokreni</string> + <string name="resume">Nastavi</string> + <string name="save">Spremi</string> + <string name="search_and_download"><![CDATA[Pretraži i preuzmi]]></string> + <string name="search">Pretraži</string> + <string name="searching">Pretraživanje…</string> + <string name="voice_search">Glasovno pretraživanje</string> + <string name="voice_starting">Pokretanje</string> + <string name="voice_search_prompt">Govorite za pretraživanje</string> + <string name="processing">Obrada</string> + <string name="press_back_to_cancel">Pritisnite Natrag za odustajanje</string> + <string name="voice_error_audio">Greška pri snimanju zvuka</string> + <string name="voice_error_client">Greška pri prepoznavanju glasa</string> + <string name="voice_error_permissions">Potrebno je dopuštenje za mikrofon</string> + <string name="voice_error_network">Greška mreže</string> + <string name="voice_error_network_timeout">Isteklo vrijeme mreže</string> + <string name="voice_error_no_match">Govor nije prepoznat</string> + <string name="voice_error_busy">Prepoznavanje glasa zauzeto</string> + <string name="voice_error_server">Greška poslužitelja</string> + <string name="voice_error_speech_timeout">Govor nije otkriven</string> + <string name="voice_error_unknown">Nepoznata greška</string> + <string name="voice_error_start_failed">Pokretanje prepoznavanja glasa nije uspjelo</string> + <string name="voice_error_timeout">Isteklo vrijeme prepoznavanja glasa</string> + <string name="retry">Pokušaj ponovno</string> + <string name="select_server">Odaberite poslužitelj</string> + <string name="select_user">Odaberite korisnika</string> + <string name="show_debug_info">Prikaži informacije za otklanjanje grešaka</string> + <string name="show_next_up_when">Prikaži \"Slijedi\"</string> + <string name="show">Prikaži</string> + <string name="shuffle">Nasumično</string> + <string name="skip">Preskoči</string> + <string name="sort_by_date_added">Datum dodavanja</string> + <string name="sort_by_date_episode_added">Datum dodavanja epizode</string> + <string name="sort_by_date_played">Datum reprodukcije</string> + <string name="sort_by_date_released">Datum izdavanja</string> + <string name="sort_by_name">Ime</string> + <string name="sort_by_random">Nasumično</string> + <string name="studios">Studiji</string> + <string name="submit">Pošalji</string> + <string name="subtitle_download_too_long">Preuzimanje traje dugo, možda ćete morati ponovno pokrenuti reprodukciju</string> + <string name="subtitle">Titl</string> + <string name="subtitles">Titlovi</string> + <string name="suggestions">Prijedlozi</string> + <string name="switch_servers">Promijeni poslužitelja</string> + <string name="switch_user">Promijeni</string> + <string name="time_left">%1$s preostalo</string> + <string name="top_unwatched">Najbolje ocijenjeno nepogledano</string> + <string name="trailers_title">Najave</string> + <string name="tv_dvr_schedule">Raspored DVR-a</string> + <string name="tv_guide">Vodič</string> + <string name="tv_season">Sezona</string> + <string name="tv_seasons">Sezone</string> + <string name="tv_shows_title">TV serije</string> + <string name="ui_interface">Sučelje</string> + <string name="unknown">Nepoznato</string> + <string name="updates">Ažuriranja</string> + <string name="version">Verzija</string> + <string name="video_scale">Skaliranje videa</string> + <string name="video">Video</string> + <string name="videos">Videozapisi</string> + <string name="watch_live">Gledaj uživo</string>g + <plurals name="years_old"> + <item quantity="one">%1$d godina</item> + <item quantity="few">%1$d godine</item> + <item quantity="other">%1$d godina</item> + </plurals> + <string name="play_with_transcoding">Reproduciraj s transkodiranjem</string> + <string name="media_information">Informacije o mediju</string> + <string name="show_clock">Prikaži sat</string> + <string name="aired_episode_order">Redoslijed emitiranja epizoda</string> + <string name="create_playlist">Stvori novi popis za reprodukciju</string> + <string name="add_to_playlist">Dodaj na popis za reprodukciju</string> + <string name="one_click_pause">Pauza jednim klikom</string> + <string name="one_click_pause_summary_on">Pritisnite sredinu D-Pada za pauzu/reprodukciju</string> + <plurals name="deleted_scenes"> + <item quantity="one">Izbrisana scena</item> + <item quantity="few">Izbrisane scene</item> + <item quantity="other">Izbrisanih scena</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Intervju</item> + <item quantity="few">Intervjua</item> + <item quantity="other">Intervjua</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scena</item> + <item quantity="few">Scene</item> + <item quantity="other">Scena</item> + </plurals> + <plurals name="samples"> + <item quantity="one">Isječak</item> + <item quantity="few">Isječaka</item> + <item quantity="other">Isječaka</item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Prateći film</item> + <item quantity="few">Prateća filma</item> + <item quantity="other">Pratećih filmova</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Kratki film</item> + <item quantity="few">Kratka filma</item> + <item quantity="other">Kratkih filmova</item> + </plurals> + <plurals name="downloads"> + <item quantity="one">%s preuzimanje</item> + <item quantity="few">%s preuzimanja</item> + <item quantity="other">%s preuzimanja</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d sat</item> + <item quantity="few">%d sata</item> + <item quantity="other">%d sati</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dan</item> + <item quantity="few">%s dana</item> + <item quantity="other">%s dana</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d stavka</item> + <item quantity="few">%d stavke</item> + <item quantity="other">%d stavki</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="few">%d sekunde</item> + <item quantity="other">%d sekundi</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minute</item> + <item quantity="other">%s minuta</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filma</item> + <item quantity="other">Filmova</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV serija</item> + <item quantity="few">TV serije</item> + <item quantity="other">TV serija</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osobe</item> + <item quantity="other">Osoba</item> + </plurals> + <string name="ac3_supported">Uređaj podržava AC3/Dolby Digital</string> + <string name="advanced_settings">Napredne postavke</string> + <string name="advanced_ui">Napredno sučelje</string> + <string name="app_theme">Tema aplikacije</string> + <string name="auto_check_for_updates">Automatski provjeravaj ažuriranja</string> + <string name="auto_play_next_delay">Odgoda prije reprodukcije sljedećeg</string> + <string name="auto_play_next">Automatska reprodukcija sljedećeg</string> + <string name="check_for_updates">Provjeri ažuriranja</string> + <string name="combine_continue_next_summary">Primjenjuje se samo na TV serije</string> + <string name="combine_continue_next"><![CDATA[Spoji \"Nastavi gledanje\" i \"Slijedi\"]]></string> + <string name="direct_play_ass">Koristi libass za ASS titlove</string> + <string name="direct_play_pgs">Izravna reprodukcija PGS titlova</string> + <string name="downmix_stereo">Uvijek smiksaj na stereo</string> + <string name="ffmpeg_extension_pref">Koristi FFmpeg modul dekodera</string> + <string name="global_content_scale">Zadano skaliranje sadržaja</string> + <string name="install_update">Instaliraj ažuriranje</string> + <string name="installed_version">Instalirana verzija</string> + <string name="max_homepage_items">Maks. stavki u redovima početne stranice</string> + <string name="nav_drawer_pins_summary">Odaberite zadane stavke za prikaz, ostale će biti skrivene</string> + <string name="nav_drawer_pins">Prilagodi stavke navigacijske ladice</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknite za promjenu stranica</string> + <string name="nav_drawer_switch_on_focus">Promijeni stranice navigacijske ladice pri fokusu</string> + <string name="pass_out_protection">Pauza kad zaspete</string> + <string name="play_theme_music">Reproduciraj tematsku glazbu</string> + <string name="playback_overrides">Nadjačavanja reprodukcije</string> + <string name="remember_selected_tab">Zapamti odabrane kartice</string> + <string name="rewatch_next_up">Omogući ponovno gledanje u \"Slijedi\"</string> + <string name="seek_bar_steps">Koraci trake za premotavanje</string> + <string name="send_app_logs_summary">Korisno za otklanjanje grešaka</string> + <string name="send_app_logs">Pošalji zapise aplikacije na trenutni poslužitelj</string> + <string name="send_crash_reports_summary">Pokušat će poslati na zadnji povezani poslužitelj</string> + <string name="send_crash_reports">Pošalji izvješća o rušenju</string> + <string name="settings">Postavke</string> + <string name="skip_back_on_resume_preference">Premotaj unatrag pri nastavku reprodukcije</string> + <string name="skip_back_preference">Premotaj unatrag</string> + <string name="skip_commercials_behavior">Ponašanje preskakanja reklama</string> + <string name="skip_forward_preference">Premotaj unaprijed</string> + <string name="skip_intro_behavior">Ponašanje preskakanja uvoda</string> + <string name="skip_outro_behavior">Ponašanje preskakanja odjave</string> + <string name="skip_previews_behavior">Ponašanje preskakanja najava</string> + <string name="skip_recap_behavior">Ponašanje preskakanja sažetka</string> + <string name="update_available">Dostupno ažuriranje</string> + <string name="update_url_summary">URL za provjeru ažuriranja aplikacije</string> + <string name="update_url">URL za ažuriranje</string> + <string name="font_size">Veličina fonta</string> + <string name="font_color">Boja fonta</string> + <string name="font_opacity">Neprozirnost fonta</string> + <string name="edge_style">Stil rubova</string> + <string name="edge_color">Boja rubova</string> + <string name="background_opacity">Neprozirnost pozadine</string> + <string name="background_style">Stil pozadine</string> + <string name="subtitle_style">Stil titlova</string> + <string name="background_color">Boja pozadine</string> + <string name="reset">Vratiti zadano</string> + <string name="bold_font">Podebljani font</string> + <string name="player_backend">Pozadinski sustav reprodukcije</string> + <string name="mpv_hardware_decoding">MPV: Koristi hardversko dekodiranje</string> + <string name="disable_if_crash">Onemogućite ako dolazi do rušenja</string> + <string name="mpv_options">MPV Opcije</string> + <string name="exoplayer_options">ExoPlayer opcije</string> + <string name="skip_segment_unknown">Preskoči segment</string> + <string name="skip_segment_commercial">Preskoči reklame</string> + <string name="skip_segment_preview">Preskoči najavu</string> + <string name="skip_segment_recap">Preskoči sažetak</string> + <string name="skip_segment_outro">Preskoči odjavu</string> + <string name="skip_segment_intro">Preskoči uvod</string> + <string name="ffmpeg_prefer">Preferiraj FFmpeg umjesto ugrađenih dekodera</string> + <string name="ffmpeg_never">Nikad ne koristi FFmpeg dekodere</string> + <string name="next_up_playback_end">Na kraju reprodukcije</string> + <string name="next_up_outro">Tijekom odjavne špice/odjave</string> + <string name="next_up_never">Nikad</string> + <string name="white">Bijela</string> + <string name="light_gray">Svijetlosiva</string> + <string name="dark_gray">Tamnosiva</string> + <string name="yellow">Žuta</string> + <string name="cyan">Tirkizna</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Obrub</string> + <string name="subtitle_edge_shadow">Sjena</string> + <string name="background_style_wrap">Obavijeno</string> + <string name="background_style_boxed">U okviru</string> + <string name="prefer_mpv">Preferiraj MPV</string> + <string name="external_player">Vanjski player</string> + <string name="player_backend_options_subtitles_prefer_mpv">Koristi ExoPlayer za HDR reprodukciju</string> + <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> + <string name="image_type_primary">Glavna</string> + <string name="image_type_thumb">Sličica</string> + <string name="view_options_type_grid">Mreža</string> + <string name="view_options_type_list">Popis</string> + <string name="view_options_type_list_compact">Zbijeni popis</string> + <string name="backdrop_style_dynamic">Slika s dinamičnom bojom</string> + <string name="backdrop_style_image">Samo slika</string> + <string name="enter_url_api_key"><![CDATA[Unesite URL i API ključ]]></string> + <string name="italic_font">Ukošeni font</string> + <string name="font">Font</string> + <string name="background">Pozadina</string> + <string name="success">Uspjeh</string> + <string name="official_rating">Roditeljska ocjena</string> + <string name="runtime_sort">Trajanje</string> + <string name="extras_title">Dodaci</string> + <string name="other_extras_title">Ostalo</string> + <string name="behind_the_scenes_title">Iza kulisa</string> + <string name="theme_songs_title">Tematske pjesme</string> + <string name="theme_videos_title">Tematski videozapisi</string> + <string name="clips_title">Isječci</string> + <string name="deleted_scenes_title">Izbrisane scene</string> + <string name="interviews_title">Intervjui</string> + <string name="scenes_title">Scene</string> + <string name="samples_title">Isječci</string> + <string name="featurettes_title">Prateći filmovi</string> + <string name="shorts_title">Kratki filmovi</string> + <string name="favorite_items_title">Favoriti %s</string> + <plurals name="trailers"> + <item quantity="one">Najava</item> + <item quantity="few">Najave</item> + <item quantity="other">Najava</item> + </plurals> + <plurals name="other_extras"> + <item quantity="one">Ostalo</item> + <item quantity="few">Ostalo</item> + <item quantity="other">Ostalo</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Iza kulisa</item> + <item quantity="few">Iza kulisa</item> + <item quantity="other">Iza kulisa</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Tematska pjesma</item> + <item quantity="few">Tematske pjesme</item> + <item quantity="other">Tematskih pjesama</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Tematski videozapis</item> + <item quantity="few">Tematska videozapisa</item> + <item quantity="other">Tematskih videozapisa</item> + </plurals> + <plurals name="clips"> + <item quantity="one">Isječak</item> + <item quantity="few">Isječka</item> + <item quantity="other">Isječaka</item> + </plurals> + <string name="played">Pogledano</string> + <string name="filter">Filtar</string> + <string name="year">Godina</string> + <string name="decade">Desetljeće</string> + <string name="remove">Ukloni</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Koristi gpu-next</string> + <string name="mpv_conf">Uredi mpv.conf</string> + <string name="discard_change">Odbaciti promjene?</string> + <string name="subtitle_margin">Margina</string> + <string name="verbose_logging">Detaljno bilježenje</string> + <string name="enter_pin">Unesite PIN</string> + <string name="sign_in_auto">Automatska prijava</string> + <string name="use_server_credentials">Prijava putem poslužitelja</string> + <string name="press_enter_to_confirm">Pritisnite srednju tipku za potvrdu</string> + <string name="require_pin_code">Zahtijevaj PIN za profil</string> + <string name="require_login">Zahtijevaj prijavu za profil</string> + <string name="confirm_pin">Potvrdite PIN</string> + <string name="incorrect">Netočno</string> + <string name="pin_too_short">PIN mora imati 4 ili više znamenki</string> + <string name="will_remove_pin">Ukloniti će PIN</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="image_cache_size">Veličina predmemorije slika na disku (MB)</string> + <string name="disable_audio_offload">Onemogući hardversko rasterećenje zvuka</string> + <string name="disable_audio_offload_summary">Onemogućavanje rasterećenja dekodiranja zvuka može riješiti zastajkivanja reprodukcije na nekim uređajima</string> + <string name="view_options">Opcije prikaza</string> + <string name="columns">Stupci</string> + <string name="spacing">Razmak</string> + <string name="aspect_ratio">Omjer slike</string> + <string name="show_details">Prikaži pojedinosti</string> + <string name="image_type">Vrsta slike</string> + <string name="cast_and_crew"><![CDATA[Glumci i filmska ekipa]]></string> + <string name="guest_stars">Gostujuće zvijezde</string> + <string name="edge_size">Veličina rubova</string> + <string name="refresh_rate_switching">Promjena frekvencije osvježavanja</string> + <string name="automatic">Automatski</string> + <string name="username_or_password">Koristi korisničko ime/lozinku</string> + <string name="login">Prijava</string> + <string name="general">Općenito</string> + <string name="container">Kontejner</string> + <string name="title">Naslov</string> + <string name="codec">Kodek</string> + <string name="profile">Profil</string> + <string name="level">Razina</string> + <string name="resolution">Razlučivost</string> + <string name="anamorphic">Anamorfni</string> + <string name="interlaced">Isprepleteni</string> + <string name="framerate">Broj sličica</string> + <string name="bit_depth">Dubina boje</string> + <string name="video_range">Raspon videa</string> + <string name="video_range_type">Vrsta raspona videa</string> + <string name="color_space">Prostor boja</string> + <string name="color_transfer">Prijenos boja</string> + <string name="color_primaries">Primarne boje</string> + <string name="pixel_format">Format piksela</string> + <string name="ref_frames">Referentne sličice</string> + <string name="nal">NAL</string> + <string name="language">Jezik</string> + <string name="layout">Raspored</string> + <string name="channels">Kanali</string> + <string name="sample_rate">Frekvencija uzorkovanja</string> + <string name="avc">AVC</string> + <string name="yes">Da</string> + <string name="no">Ne</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">Prikaži naslove</string> + <string name="live_tv_repeat">Ponavljanje</string> + <string name="favorite_channels_at_beginning">Prvo prikaži omiljene kanale</string> + <string name="sort_channels_recently_watched">Sortiraj kanale po nedavno gledanima</string> + <string name="color_code_programs">Programi u boji</string> + <string name="subtitle_delay">Kašnjenje titlova</string> + <string name="backdrop_display">Stil naslovne slike</string> + <string name="combined_search_results">Kombinirani rezultati pretraživanja</string> + <string name="combined_search_results_on">Prikaži sve rezultate na jednom popisu</string> + <string name="combined_search_results_off">Prikaži rezultate po kategoriji</string> + <string name="show_voice_search_button">Prikaži gumb za glasovno pretraživanje</string> + <string name="visible_ui">Vidljivo</string> + <string name="hidden_ui">Skriveno</string> + <string name="results">Rezultati</string> + <string name="resolution_switching">Promjena razlučivosti</string> + <string name="local">Lokalno</string> + <string name="play_trailer">Reproduciraj najavu</string> + <string name="no_trailers">Bez najava</string> + <string name="clear_track_choices">Očisti odabir zapisa</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</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">Izravna reprodukcija Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Zanemaruje provjere kompatibilnosti uređaja</string> + <string name="discover">Otkrij</string> + <string name="request">Zatraži</string> + <string name="pending">Na čekanju</string> + <string name="seerr_integration">Seerr integracija</string> + <string name="remove_seerr_server">Ukloni Seerr poslužitelj</string> + <string name="seerr_server_added">Seerr poslužitelj dodan</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Autorizirajte drugi uređaj za prijavu na vaš račun</string> + <string name="quick_connect_code">Unesite Quick Connect kod</string> + <string name="quick_connect_code_error">Kod mora imati 6 znamenki</string> + <string name="quick_connect_success">Uređaj uspješno autoriziran</string> + <string name="password">Lozinka</string> + <string name="username">Korisničko ime</string> + <string name="url">URL</string> + <string name="trending">U trendu</string> + <string name="upcoming_movies">Nadolazeći filmovi</string> + <string name="upcoming_tv">Nadolazeće TV serije</string> + <string name="request_4k">Zatraži u 4K</string> + <string name="software_decoding_av1">AV1 programsko dekodiranje</string> + <string name="upgrade_mpv_toast">MPV je sada zadani player osim za HDR.\nOvo možete promijeniti u postavkama.</string> + <string name="hdr_subtitle_style">HDR stil titlova</string> + <string name="image_subtitle_opacity">Neprozirnost slikovnih titlova</string> + <string name="brightness">Svjetlina</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Zasićenost</string> + <string name="hue">Nijansa</string> + <string name="red">Crvena</string> + <string name="green">Zelena</string> + <string name="blue">Plava</string> + <string name="blur">Zamućenje</string> + <string name="save_for_album">Spremi u album</string> + <string name="play_slideshow">Pokreni prezentaciju</string> + <string name="stop_slideshow">Zaustavi prezentaciju</string> + <string name="slideshow_at_beginning">Na početak</string> + <string name="no_more_images">Nema više fotografija</string> + <string name="rotate_left">Rotiraj ulijevo</string> + <string name="rotate_right">Rotiraj udesno</string> + <string name="zoom_in">Približi</string> + <string name="zoom_out">Udalji</string> + <string name="slideshow_duration">Trajanje prezentacije</string> + <string name="play_videos_during_slideshow">Reproduciraj videozapise tijekom prezentacije</string> + <string name="send_media_info_log_to_server">Pošalji zapis o mediju na poslužitelj</string> + <string name="no_limit">Bez ograničenja</string> + <string name="max_days_next_up">Maks. dana u \"Slijedi\"</string> + <string name="albums">Albumi</string> + <string name="artists">Izvođači</string> + <string name="songs">Pjesme</string> + <string name="go_to_artist">Idi na izvođača</string> + <string name="now_playing">Trenutno svira</string> + <string name="instant_mix">Trenutni miks</string> + <string name="go_to_album">Idi na album</string> + <string name="add_to_queue">Dodaj u red čekanja</string> + <string name="add_row">Dodaj red</string> + <string name="genres_in">Žanrovi u %1$s</string> + <string name="recently_released_in">Nedavno objavnjeno u %1$s</string> + <string name="height">Visina</string> + <string name="apply_all_rows">Primijeni na sve redove</string> + <string name="customize_home">Prilagodi početnu stranicu</string> + <string name="home_rows">Redovi početne stranice</string> + <string name="load_from_server">Učitaj s korisničkog profila poslužitelja</string> + <string name="save_to_server">Spremi na korisnički profil poslužitelja</string> + <string name="load_from_web_client">Učitaj s web klijenta</string> + <string name="use_series">Koristi sliku serije</string> + <string name="add_row_for">Dodaj red za %1$s</string> + <string name="overwrite_server_settings">Prebrisati postavke na poslužitelju?</string> + <string name="overwrite_local_settings">Prebrisati lokalne postavke?</string> + <string name="for_episodes">Za epizode</string> + <string name="suggestions_for">Prijedlozi za %1$s</string> + <string name="increase_all_cards_size">Povećaj veličinu svih kartica</string> + <string name="decrease_all_cards_size">Smanji veličinu svih kartica</string> + <string name="use_thumb_images">Koristi sličice</string> + <string name="settings_saved">Postavke spremljene</string> + <string name="display_presets">Zadane postavke prikaza</string> + <string name="display_presets_description">Ugrađene postavke za brzo oblikovanje svih redova</string> + <string name="customize_home_summary">Odaberite redove i slike na početnoj stranici</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkodiraj ne-stereo zvuk u AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Koristi AC3 za surround zvuk</string> + <string name="start_after">Počni nakon</string> + <string name="animate">Animiraj</string> + <string name="max_age_rating">Maks. dobna ocjena</string> + <string name="no_max">Bez maksimuma</string> + <string name="for_all_ages">Za sve uzraste</string> + <string name="up_to_age">Do dobi %s</string> + <string name="screensaver">Čuvar zaslona</string> + <string name="screensaver_settings">Postavke čuvara zaslona</string> + <string name="start_screensaver">Pokreni čuvar zaslona</string> + <string name="duration">Trajanje</string> + <string name="photos">Fotografije</string> + <string name="include_types">Uključi vrste</string> + <string name="include_types_summary">Za koje vrste stavki prikazivati slike</string> + <string name="content_scale_fit">Prilagodi</string> + <string name="content_scale_crop">Obreži</string> + <string name="content_scale_fill">Ispuni</string> + <string name="content_scale_fill_width">Ispuni širinu</string> + <string name="content_scale_fill_height">Ispuni visinu</string> + <string name="display_preset_default">Wholphin zadani</string> + <string name="display_preset_compact">Wholphin zbijeni</string> + <string name="display_preset_series_thumb">Slike sličica serije</string> + <string name="display_preset_episode_thumbnails">Slike sličica epizoda</string> + <string name="volume_lowest">Najniža</string> + <string name="volume_low">Niska</string> + <string name="volume_medium">Srednja</string> + <string name="volume_high">Visoka</string> + <string name="volume_full">Puna</string> + <string name="purple">Ljubičasta</string> + <string name="orange">Narančasta</string> + <string name="bold_blue">Jarko plava</string> + <string name="black">Crna</string> + <string name="brown">Smeđa</string> + <string name="skip_ignore">Zanemari</string> + <string name="skip_automatically">Automatski preskoči</string> + <string name="skip_ask">Pitaj za preskakanje</string> + <string name="ffmpeg_fallback">Koristi FFmpeg samo ako ne postoji ugrađeni dekoder</string> + <string name="api_key">API ključ</string> + <string name="seerr_login">Prijava na Seerr poslužitelj</string> + <string name="seerr_jellyfin_user">Jellyfin korisnik</string> + <string name="seerr_local_user">Lokalni korisnik</string> + <string name="search_and_download_subtitles"><![CDATA[Pretraži i preuzmi titlove]]></string> + <string name="no_subtitles_found">Nisu pronađeni udaljeni titlovi</string> + <string name="series_continueing">U tijeku</string> + <string name="in_app_screensaver">Koristi ugrađeni čuvar zaslona</string> + <string name="delete_item">Sigurno želite izbrisati ovu stavku?</string> + <string name="show_media_management">Prikaži opcije upravljanja medijima</string> + <string name="search_for">Pretraži %s</string> + <string name="actor">Glumac</string> + <string name="composer">Skladatelj</string> + <string name="writer">Scenarist</string> + <string name="guest_star">Gostujuća zvijezda</string> + <string name="producer">Producent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstopisac</string> + <string name="arranger">Aranžer</string> + <string name="engineer">Tonski inženjer</string> + <string name="mixer">Miks majstor</string> + <string name="creator">Autor</string> + <string name="artist">Izvođač</string> + <string name="album_artist">Izvođač albuma</string> + <string name="album">Album</string> + <string name="popular_songs">Popularne pjesme</string> + <string name="play_next">Reproduciraj sljedeće</string> + <string name="music_videos">Glazbeni spotovi</string> + <string name="lyrics">Tekst pjesme</string> + <string name="hide_lyrics">Sakrij tekst</string> + <string name="show_lyrics">Prikaži tekst</string> + <string name="song_has_lyrics">Pjesma ima tekst</string> + <string name="remove_from_queue">Ukloni iz reda čekanja</string> + <string name="show_album_cover">Prikaži omot albuma</string> + <string name="show_visualizer">Prikaži vizualizator</string> + <string name="show_backdrop">Prikaži pozadinsku sliku</string> + <string name="play_as_type">Reproduciraj kao?</string> + <string name="visualizer_rationale">Vizualizacija zvuka koji se trenutno reproducira zahtijeva dopuštenje za snimanje zvuka.</string> + <string name="continue_string">Nastavi</string> + <string name="select_all">Odaberi sve</string> + <string name="separate_types">Odvojene vrste</string> + <string name="prefer_logos">Preferiraj prikaz logotipa za naslove</string> + <string name="updated_toast">Wholphin ažuriran na %s</string> + <string name="remove_continue_watching">Ukloni iz \"Nastavi gledanje\"</string> + <string name="remove_next_up">Ukloni seriju iz \"Slijedi\"</string> + <string name="view_removed_next_up">Prikaži serije uklonjene iz \"Slijedi\"</string> + <string name="view_more">Prikaži više</string> + <string name="discover_tv">Otkrij TV serije</string> + <string name="discover_movies">Otkrij filmove</string> + <string name="studios_in">Studiji u %1$s</string> + <string name="last_played">Zadnje reproducirano</string> + <string name="system_default">Zadano sustavom</string> + <string name="ass_subtitle_mode_libass">Izravna reprodukcija s libass</string> + <string name="ass_subtitle_mode_exoplayer">Izravna reprodukcija s ExoPlayer ugrađenim</string> + <string name="ass_subtitle_mode_transcode">Ugradi/transkodiraj na poslužitelju</string> + <string name="ass_subtitle_playback">Reprodukcija SSA/ASS titlova</string> + <string name="cinema_mode">Kino način</string> + <string name="show_more">Prikaži %1$s više</string> + <string name="skip_behavior">Ponašanje preskakanja</string> + <string name="skip_behavior_summary">Za uvode, špice itd.</string> + <string name="play_with">Reproduciraj pomoću</string> + <string name="transcoding">Transkodiranje</string> + <string name="user_interface_language">Jezik korisničkog sučelja</string> + <string name="display_toggles_title">Prikaz ocjena</string> + <string name="display_toggles_summary">Prilagodite što prikazati</string> + <string name="profile_protection">Zaštita profila</string> + <string name="continue_watching_click_behavior">Ponašanje klika za Nastavi gledanje/Slijedi</string> + <string name="continue_watching_click_summary_on">Reproduciraj na klik</string> + <string name="continue_watching_click_summary_off">Prikaži pojedinosti na klik</string> + <string name="stars">Zvjezdice</string> + <string name="tomatoes">Rajčice</string> + <string name="sort">Sortiraj</string> + <string name="split_into_separate_rows">Podijeli u odvojene redove</string> + <string name="genre_movies">%1$s filmovi</string> + <string name="genre_tv_shows">%1$s TV serije</string> + <string name="quality_profile">Profil kvalitete</string> + <string name="is_default">Zadano</string> + <string name="root_folder">Korijenska mapa</string> + <string name="popularity">Popularnost</string> + <string name="production_status">Status produkcije</string> + <string name="different_for_hdr">Koristi drugačiji stil za HDR</string> + <string name="experimental_settings">Eksperimentalne postavke</string> + <string name="confirm_enable_experimental_title">Omogućiti eksperimentalne postavke?</string> + <string name="confirm_enable_experimental_body">Eksperimentalne postavke mogu biti nestabilne!\n\n Mogu biti uklonjene ili promijenjene u bilo kojem izdanju.</string> + <string name="video_tunneling">Video tunneling</string> + <string name="d_pad_seek_mode_title">Način trake za premotavanje D-Padom</string> + <string name="dpad_seek_mode_minimal">Minimalno</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Mala traka za premotavanje uz dno zaslona (zadano)</string> + <string name="dpad_seek_mode_trickplay_summary">Traka za premotavanje s trickplay slikama pretpregleda</string> + <string name="dpad_seek_mode_none_summary">Bez trake za premotavanje</string> + <string name="blocklisted">Na popisu blokiranih</string> + <string name="unavailable">Nedostupno</string> + <string name="subtitle_mode">Način titlova</string> + <string name="use_user_profile">Koristi postavku vašeg profila</string> + <string name="subtitle_mode_default">Zadano</string> + <string name="subtitle_mode_smart">Pametno</string> + <string name="subtitle_mode_only_forced">Samo prisilni</string> + <string name="subtitle_mode_always">Uvijek</string> + <string name="subtitle_mode_none">Ništa</string> + <string name="preferred_subtitle_language">Preferirani jezik titlova</string> + <string name="preferred_audio_language">Preferirani jezik zvuka</string> + <string name="override_user_profile_settings">Više postavki korisničkog profila</string> + <string name="override_user_profile_settings_summary">Nadjačaj postavke korisničkog profila s poslužitelja</string> + <string name="any_language">Bilo koji jezik</string> + <string name="hash_matches">Podudaranja hasha</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programi</string> + <string name="recordings">Snimke</string> + <string name="folders_title">Mape</string> + <string name="user_views">Korisnički pogledi</string> + <string name="books">Knjige</string> + <string name="audio_books">Audioknjige</string> + <string name="server_version_not_supported">Verzija poslužitelja nije podržana</string> + <string name="appears_on">Pojavljuje se na</string> + <string name="dim_after_delay">Zatamni zaslon nakon odgode</string> + <string name="dim_percentage">Postotak zatamnjenja</string> + <string name="dim_screen">Zatamni zaslon</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Sezona %1$d</string> + <string name="photo_albums">Albumi slika</string> + <string name="no_favorites_found">Nijedan favorit nije pronađen!</string> +</resources> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index f292b891..64d8c3b1 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -25,7 +25,7 @@ <string name="director">Rendező</string> <string name="disabled">Letiltva</string> <string name="discovered_servers">Felfedezett szerverek</string> - <string name="download_and_update"><![CDATA[Letöltés & Frissítés]]></string> + <string name="download_and_update">Letöltés & Frissítés</string> <string name="downloading">Letöltés folyamatban…</string> <string name="enabled">Engedélyezve</string> <string name="enter_server_url">Adja meg a szerver IP-t vagy URL-t</string> @@ -62,7 +62,7 @@ <string name="max_bitrate">Maximális bitráta</string> <string name="more_like_this">Hasonlóak</string> <string name="more">Továbbiak</string> - <string name="movies">Filmek</string> + <string name="movies_title">Filmek</string> <string name="name">Név</string> <string name="next_up">Következő</string> <string name="no_data">Nincs adat</string> @@ -72,7 +72,7 @@ <string name="none">Nincs</string> <string name="outro">Stáblista</string> <string name="path">Elérési út</string> - <string name="people">Stábtagok</string> + <string name="people_title">Stábtagok</string> <string name="play_count">Lejátszások száma</string> <string name="play_from_here">Lejátszás innen</string> <string name="play">Lejátszás</string> @@ -95,7 +95,7 @@ <string name="restart">Újraindítás</string> <string name="resume">Folytatás</string> <string name="save">Mentés</string> - <string name="search_and_download"><![CDATA[Keresés & Letöltés]]></string> + <string name="search_and_download">Keresés & Letöltés</string> <string name="search">Keresés</string> <string name="searching">Keresés…</string> <string name="select_server">Szerver kiválasztása</string> @@ -120,16 +120,15 @@ <string name="switch_servers">Váltás másik szerverre</string> <string name="switch_user">Váltás</string> <string name="top_unwatched">Legjobbra értékelt, nem megnézett</string> - <string name="trailer">Előzetes</string> <plurals name="trailers"> <item quantity="one">Előzetesek</item> - <item quantity="other"/> + <item quantity="other">Előzetesek</item> </plurals> <string name="tv_dvr_schedule">DVR Időzítés</string> <string name="tv_guide">Műsorújság</string> <string name="tv_season">Évad</string> <string name="tv_seasons">Évadok</string> - <string name="tv_shows">Sorozatok</string> + <string name="tv_shows_title">Sorozatok</string> <string name="ui_interface">Kezelőfelület</string> <string name="unknown">Ismeretlen</string> <string name="updates">Frissítések</string> @@ -138,7 +137,9 @@ <string name="video">Videó</string> <string name="videos">Videók</string> <string name="watch_live">Nézze élőben</string> - <string name="years_old">%1$d éves</string> + <plurals name="years_old"> + <item quantity="other">%1$d éves</item> + </plurals> <string name="play_with_transcoding">Lejátszás átalakítással</string> <string name="show_clock">Aktuális idő mutatása</string> <string name="aired_episode_order">Sorrend epizód vetítése alapján</string> @@ -152,14 +153,14 @@ <string name="success">Sikeres</string> <string name="official_rating">Korhatár-besorolás</string> <string name="runtime_sort">Hossz</string> - <string name="extras">Extrák</string> + <string name="extras_title">Extrák</string> <plurals name="other_extras"> <item quantity="one">Egyéb</item> <item quantity="other">Egyebek</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Színfalak mögött</item> - <item quantity="other"/> + <item quantity="other">Színfalak mögött</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Főcímdal</item> @@ -353,10 +354,10 @@ <string name="subtitle_delay">Felirat késleltetése</string> <string name="backdrop_display">Háttér stílusa</string> <string name="enter_server_address">Adja meg a szerver címét</string> - <string name="combine_continue_next"><![CDATA[Folytatás & Következő menü összevonása]]></string> + <string name="combine_continue_next">Folytatás & Következő menü összevonása</string> <string name="player_backend">Lejátszás backend</string> <string name="image_cache_size">Kép-gyorsítótár mérete (MB)</string> - <string name="cast_and_crew"><![CDATA[Stábtagok]]></string> + <string name="cast_and_crew">Stábtagok</string> <string name="nav_drawer_switch_on_focus">Navigációs menü oldalainak váltása fókusznál</string> <string name="playback_overrides">Lejátszás felülbírálatai</string> <string name="resolution_switching">Felbontásváltás</string> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c5199c5b..f3c0a00c 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -62,7 +62,7 @@ <string name="max_bitrate">Bitrate maks</string> <string name="more_like_this">Tampilkan yang serupa</string> <string name="more">Lainnya</string> - <string name="movies">Film</string> + <string name="movies_title">Film</string> <string name="name">Nama</string> <string name="next_up">Selanjutnya</string> <string name="no_data">Tidak ada data</string> @@ -72,7 +72,7 @@ <string name="none">Tidak ada</string> <string name="outro">Outro</string> <string name="path">Berkas</string> - <string name="people">Orang</string> + <string name="people_title">Orang</string> <string name="play_count">Total Pemutaran</string> <string name="play_from_here">Putar dari sini</string> <string name="play">Putar</string> @@ -121,7 +121,6 @@ <string name="switch_servers">Ganti Server</string> <string name="switch_user">Ganti</string> <string name="top_unwatched">Terbaik Belum Ditonton</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="other">Trailer</item> </plurals> @@ -129,7 +128,7 @@ <string name="tv_guide">Panduan</string> <string name="tv_season">Season</string> <string name="tv_seasons">Season</string> - <string name="tv_shows">Acara TV</string> + <string name="tv_shows_title">Acara TV</string> <string name="ui_interface">Antarmuka</string> <string name="unknown">Tidak Diketahui</string> <string name="updates">Pembaruan</string> @@ -138,7 +137,9 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Tonton langsung</string> - <string name="years_old">%1$d tahun</string> + <plurals name="years_old"> + <item quantity="other">%1$d tahun</item> + </plurals> <string name="play_with_transcoding">Putar dengan transcoding</string> <string name="show_clock">Tampilkan Jam</string> <string name="aired_episode_order">Urutan Episode Tayang</string> @@ -152,24 +153,24 @@ <string name="success">Berhasil</string> <string name="official_rating">Rating Orang Tua</string> <string name="runtime_sort">Durasi</string> - <string name="extras">Ekstra</string> + <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="other">Lainnya</item> </plurals> <plurals name="behind_the_scenes"> - <item quantity="other">Di Balik Layar</item> + <item quantity="other">Di balik layar</item> </plurals> <plurals name="theme_songs"> - <item quantity="other">Lagu Tema</item> + <item quantity="other">Lagu tema</item> </plurals> <plurals name="theme_videos"> - <item quantity="other">Video Tema</item> + <item quantity="other">Video tema</item> </plurals> <plurals name="clips"> <item quantity="other">Klip</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="other">Adegan Dihapus</item> + <item quantity="other">Adegan dihapus</item> </plurals> <plurals name="interviews"> <item quantity="other">Wawancara</item> @@ -184,7 +185,7 @@ <item quantity="other">Featurette</item> </plurals> <plurals name="shorts"> - <item quantity="other">Klip Pendek</item> + <item quantity="other">Klip pendek</item> </plurals> <plurals name="hours"> <item quantity="other">%d jam</item> @@ -446,7 +447,7 @@ <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> + <string name="favorite_items_title">Favoritkan %s</string> <string name="artist">Artis</string> <plurals name="minutes"> <item quantity="other">%s menit</item> @@ -582,7 +583,7 @@ <string name="play_with">Putar dengan</string> <string name="skip_behavior_summary">Untuk intro, kredit, dll</string> <string name="skip_behavior">Perilaku lewati</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Hasil pencarian gabungan</string> <string name="combined_search_results_on">Tampilkan semua hasil dalam satu daftar</string> <string name="combined_search_results_off">Tampilkan hasil berdasarkan kategori</string> @@ -603,4 +604,94 @@ <string name="continue_watching_click_summary_off">Tampilkan detail saat diklik</string> <string name="stars">Bintang</string> <string name="tomatoes">Tomatoes</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Lainnya</string> + <string name="behind_the_scenes_title">Di Balik Layar</string> + <string name="theme_songs_title">Lagu Tema</string> + <string name="theme_videos_title">Video Tema</string> + <string name="clips_title">Klip</string> + <string name="deleted_scenes_title">Adegan Dihapus</string> + <plurals name="movies"> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">Acara TV</item> + </plurals> + <string name="interviews_title">Wawancara</string> + <string name="scenes_title">Adegan</string> + <string name="samples_title">Sampel</string> + <string name="featurettes_title">Featurette</string> + <string name="shorts_title">Shorts</string> + <string name="sort">Urutkan</string> + <string name="split_into_separate_rows">Pisahkan menjadi baris terpisah</string> + <string name="genre_movies">%1$s Film</string> + <string name="genre_tv_shows">%1$s Acara TV</string> + <string name="quality_profile">Profil kualitas</string> + <string name="is_default">Default</string> + <string name="root_folder">Folder root</string> + <string name="popularity">Popularitas</string> + <string name="production_status">Status produksi</string> + <string name="different_for_hdr">Gunakan gaya berbeda untuk HDR</string> + <string name="experimental_settings">Pengaturan eksperimental</string> + <string name="confirm_enable_experimental_title">Aktifkan pengaturan eksperimental?</string> + <string name="confirm_enable_experimental_body">Pengaturan eksperimental mungkin tidak stabil!\n\nPengaturan ini dapat dihapus atau diubah pada rilisan mana pun.</string> + <string name="video_tunneling">Video tunneling</string> + <plurals name="people"> + <item quantity="other">Orang</item> + </plurals> + <string name="prefer_ac3_for_surround">Gunakan AC3 untuk surround sound</string> + <string name="duration_hours_minutes_seconds">%1$dj %2$dm %3$dd</string> + <string name="duration_hours_minutes">%1$dj %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$dd</string> + <string name="duration_hours">%1$dj</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$dd</string> + <string name="time_left">Sisa %1$s</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode audio non-stereo ke AC3/Dolby Digital]]></string> + <string name="unavailable">Tidak tersedia</string> + <string name="profile_specific_settings_from_server">Pengaturan profil pengguna dari server</string> + <string name="subtitle_mode">Mode subtitel</string> + <string name="use_user_profile">Gunakan pengaturan profil kamu</string> + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Pintar</string> + <string name="subtitle_mode_only_forced">Hanya Paksaan</string> + <string name="subtitle_mode_always">Selalu</string> + <string name="subtitle_mode_none">Tidak ada</string> + <string name="preferred_subtitle_language">Bahasa subtitel preferensi</string> + <string name="preferred_audio_language">Bahasa audio preferensi</string> + <string name="override_user_profile_settings">Pengaturan profil pengguna lainnya</string> + <string name="override_user_profile_settings_summary">Timpa pengaturan profil pengguna dari server</string> + <string name="any_language">Semua bahasa</string> + <string name="brown">Cokelat</string> + <string name="d_pad_seek_mode_title">Mode bilah geser D-Pad</string> + <string name="duration_hours_seconds">%1$dj %2$dd</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Season %1$d</string> + <string name="disable_audio_offload">Nonaktifkan offload audio hardware</string> + <string name="disable_audio_offload_summary">Menonaktifkan dekode audio offload dapat mengatasi masalah pemutaran macet pada beberapa perangkat</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="dpad_seek_mode_minimal_summary">Bilah geser kecil di sepanjang bawah layar (default)</string> + <string name="dpad_seek_mode_trickplay_summary">Bilah geser dengan gambar pratinjau trickplay</string> + <string name="dpad_seek_mode_none_summary">Tanpa bilah geser</string> + <string name="blocklisted">Daftar blokir</string> + <string name="hash_matches">Hash sesuai</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Acara TV</string> + <string name="recordings">Rekaman</string> + <string name="folders_title">Folder</string> + <string name="user_views">Tampilan pengguna</string> + <string name="books">Buku</string> + <string name="audio_books">Buku audio</string> + <string name="server_version_not_supported">Versi server tidak didukung</string> + <string name="appears_on">Tampil pada</string> + <string name="dim_after_delay">Redupkan layar setelah penundaan</string> + <string name="dim_percentage">Persentase redup</string> + <string name="dim_screen">Redupkan layar</string> + <string name="photo_albums">Album foto</string> + <string name="no_favorites_found">Favorit tidak ditemukan!</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d81f34a5..7e347b2d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -34,7 +34,7 @@ <string name="loading">Caricamento…</string> <string name="library">Libreria</string> <string name="live_tv">Diretta TV</string> - <string name="movies">Film</string> + <string name="movies_title">Film</string> <string name="no_results">Nessun risultato</string> <string name="max_bitrate">Bitrate massimo</string> <string name="mark_unwatched">Segna come non visto</string> @@ -69,7 +69,7 @@ <string name="sort_by_random">Casuale</string> <string name="tv_season">Stagione</string> <string name="tv_seasons">Stagioni</string> - <string name="tv_shows">Serie TV</string> + <string name="tv_shows_title">Serie TV</string> <string name="ui_interface">Interfaccia</string> <string name="unknown">Sconosciuto</string> <string name="updates">Aggiornamenti</string> @@ -81,24 +81,24 @@ <string name="font">Carattere</string> <string name="background">Sfondo</string> <plurals name="behind_the_scenes"> - <item quantity="one">Dietro le Scena</item> - <item quantity="many">Dietro le Scene</item> - <item quantity="other">Dietro le Scene</item> + <item quantity="one">Dietro la scena</item> + <item quantity="many">Dietro le scene</item> + <item quantity="other">Dietro le scene</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Sigla</item> - <item quantity="many">sigle</item> - <item quantity="other">sigle</item> + <item quantity="many">Sigle</item> + <item quantity="other">Sigle</item> </plurals> <plurals name="interviews"> - <item quantity="one">Interviste</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Intervista</item> + <item quantity="many">Interviste</item> + <item quantity="other">Interviste</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scene</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scena</item> + <item quantity="many">Scene</item> + <item quantity="other">Scene</item> </plurals> <string name="watch_live">Guarda diretta</string> <string name="tv_guide">Guida TV</string> @@ -110,7 +110,7 @@ <string name="critic_rating">Valutazione della critica</string> <string name="default_track">Predefinita</string> <string name="discovered_servers">Server Scoperti</string> - <string name="download_and_update"><![CDATA[Scarica & Aggiorna]]></string> + <string name="download_and_update"><![CDATA[Scarica e Aggiorna]]></string> <string name="downloading">Scaricando…</string> <string name="forced_track">Forzata</string> <string name="go_to">Vai a</string> @@ -122,7 +122,7 @@ <string name="hide_debug_info">Nascondi informazioni debug</string> <string name="font_color">Colore testo</string> <string name="font_opacity">Opacità testo</string> - <string name="extras">Extra</string> + <string name="extras_title">Extra</string> <plurals name="other_extras"> <item quantity="one">Altro</item> <item quantity="many">Altri</item> @@ -164,7 +164,7 @@ <string name="name">Nome</string> <string name="next_up">Prossimo Episodio</string> <string name="no_scheduled_recordings">Nessuna registrazione programmata</string> - <string name="people">Persone</string> + <string name="people_title">Persone</string> <string name="show_debug_info">Mostra informazioni debug</string> <string name="sort_by_date_added">Data di Aggiunta</string> <string name="sort_by_date_played">Data di riproduzione</string> @@ -174,17 +174,20 @@ <string name="suggestions">Suggerimenti</string> <string name="switch_servers">Cambia server</string> <string name="switch_user">Cambia</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">I trailer</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="many">Trailer</item> + <item quantity="other">Trailer</item> </plurals> <string name="tv_dvr_schedule">Programma DVR</string> <string name="video_scale">Scala Video</string> <string name="video">Video</string> <string name="videos">I video</string> - <string name="years_old">%1$d anni</string> + <plurals name="years_old"> + <item quantity="one">%1$d anno</item> + <item quantity="many">%1$d anni</item> + <item quantity="other">%1$d anni</item> + </plurals> <string name="play_with_transcoding">Riproduci con transcodifica</string> <string name="immediate">Immediato</string> <string name="show_next_up_when">Mostra prossimo episodio</string> @@ -213,19 +216,19 @@ <string name="background_opacity">Opacità sfondo</string> <string name="skip_segment_outro">Salta Outro</string> <plurals name="theme_videos"> - <item quantity="one">Temi Video</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Tema Video</item> + <item quantity="many">Temi Video</item> + <item quantity="other">Temi Video</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Scene Eliminate</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scena Eliminata</item> + <item quantity="many">Scene Eliminate</item> + <item quantity="other">Scene Eliminate</item> </plurals> <plurals name="shorts"> - <item quantity="one">Video brevi</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Video breve</item> + <item quantity="many">Video brevi</item> + <item quantity="other">Video brevi</item> </plurals> <string name="advanced_settings">Impostazioni Avanzate</string> <string name="check_for_updates">Cerca aggiornamenti</string> @@ -254,14 +257,14 @@ </plurals> <string name="remember_selected_tab">Ricorda schede selezionate</string> <plurals name="samples"> - <item quantity="one">Campioni</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Campione</item> + <item quantity="many">Campioni</item> + <item quantity="other">Campioni</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Featurettes</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Contenuto extra</item> + <item quantity="many">Contenuti extra</item> + <item quantity="other">Contenuti extra</item> </plurals> <string name="combine_continue_next"><![CDATA[Combina Continua a Guardare & Prossimo Episodio]]></string> <string name="downmix_stereo">Converti sempre in stereo</string> @@ -271,8 +274,8 @@ <string name="runtime_sort">Durata</string> <plurals name="clips"> <item quantity="one">Clip</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="many">Clip</item> + <item quantity="other">Clip</item> </plurals> <plurals name="downloads"> <item quantity="one">%s scaricamento</item> @@ -480,7 +483,7 @@ <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> + <string name="favorite_items_title">Preferito %s</string> <string name="content_scale_fit">Adatta</string> <string name="content_scale_crop">Ritaglia</string> <string name="content_scale_fill">Riempi</string> @@ -517,7 +520,7 @@ <string name="subtitle_edge_shadow">Ombra</string> <string name="background_style_wrap">Avvolgi</string> <string name="background_style_boxed">Con riquadro</string> - <string name="prefer_mpv">Preferisci MVP</string> + <string name="prefer_mpv">Preferisci MPV</string> <string name="player_backend_options_subtitles_prefer_mpv">Usa ExoPlayer per la riproduzione HDR</string> <string name="aspect_ratios_poster">Copertina (2:3)</string> <string name="aspect_ratios_16_9">16:9</string> @@ -618,7 +621,7 @@ <string name="skip_behavior_summary">Per intro, titoli di coda e altro</string> <string name="play_with">Riproduci con</string> <string name="transcoding">Trascodifica</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="results">Risultati</string> <string name="require_login">Richiedi login per il profilo</string> <string name="combined_search_results">Risultati di ricerca combinati</string> @@ -629,4 +632,113 @@ <string name="continue_watching_click_summary_off">Mostra dettagli al click</string> <string name="stars">Stelle</string> <string name="tomatoes">Pomodori</string> + <string name="show_voice_search_button">Mostra pulsante di ricerca vocale</string> + <string name="visible_ui">Visibile</string> + <string name="hidden_ui">Nascosto</string> + <string name="view_options_type_grid">Griglia</string> + <string name="view_options_type_list">Lista</string> + <string name="split_into_separate_rows">Dividi in righe separate</string> + <string name="view_options_type_list_compact">Lista compatta</string> + <string name="user_interface_language">Lingua dell\'interfaccia</string> + <string name="display_toggles_title">Visualizzazione valutazioni</string> + <string name="display_toggles_summary">Personalizza quali mostrare</string> + <string name="continue_watching_click_behavior">Comportamento click su Continua a guardare / Prossimo</string> + <string name="sort">Ordina</string> + <string name="trailers_title">Trailer</string> + <string name="other_extras_title">Altri</string> + <string name="behind_the_scenes_title">Dietro le scene</string> + <string name="theme_songs_title">Sigle</string> + <string name="theme_videos_title">Video delle sigle</string> + <string name="clips_title">Clip</string> + <string name="deleted_scenes_title">Scene cancellate</string> + <string name="interviews_title">Interviste</string> + <string name="scenes_title">Scene</string> + <string name="samples_title">Campioni</string> + <string name="shorts_title">Corti</string> + <string name="featurettes_title">Contenuti extra</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="many">Film</item> + <item quantity="other">Film</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie TV</item> + <item quantity="many">Serie TV</item> + <item quantity="other">Serie TV</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persona</item> + <item quantity="many">Persone</item> + <item quantity="other">Persone</item> + </plurals> + <string name="genre_movies">%1$s Film</string> + <string name="genre_tv_shows">%1$s Serie TV</string> + <string name="quality_profile">Profilo di qualità</string> + <string name="is_default">Predefinito</string> + <string name="root_folder">Cartella principale</string> + <string name="popularity">Popolarità</string> + <string name="production_status">Stato di produzione</string> + <string name="different_for_hdr">Usa stile differente per HDR</string> + <string name="experimental_settings">Impostazioni sperimentali</string> + <string name="confirm_enable_experimental_title">Abilita impostazioni sperimentali?</string> + <string name="confirm_enable_experimental_body">Le impostazioni sperimentali possono essere instabili!\nPossono essere rimosse o cambiate in qualsiasi aggiornamento.</string> + <string name="video_tunneling">Tunneling video</string> + <string name="duration_hours_minutes_seconds">%1$do %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$do %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$do</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s rimanenti</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodifica l’audio non stereo in AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usa AC3 per l’audio surround</string> + <string name="blocklisted">Bloccati</string> + <string name="unavailable">Non disponibile</string> + <string name="subtitle_mode_default">Predefinito</string> + <string name="subtitle_mode_none">Nessuno</string> + <string name="profile_specific_settings_from_server">Impostazioni profilo utente dal server</string> + <string name="subtitle_mode">Modalità sottotitoli</string> + <string name="use_user_profile">Usa le impostazioni del tuo profilo</string> + <string name="subtitle_mode_smart">Intelligente</string> + <string name="subtitle_mode_only_forced">Solo forzati</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="preferred_subtitle_language">Lingua preferita per i sottotitoli</string> + <string name="preferred_audio_language">Lingua preferita per l\'audio</string> + <string name="override_user_profile_settings">Altre impostazioni del profilo utente</string> + <string name="override_user_profile_settings_summary">Sovrascrivi le impostazioni del tuo profilo utente dal server</string> + <string name="any_language">Qualsiasi lingua</string> + <string name="brown">Marrone</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programmi TV</string> + <string name="recordings">Registrazioni</string> + <string name="folders_title">Cartelle</string> + <string name="books">Libri</string> + <string name="d_pad_seek_mode_title">Modalità barra di avanzamento con D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimale</string> + <string name="dpad_seek_mode_minimal_summary">Barra di avanzamento piccola in basso allo schermo (predefinita)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra di avanzamento con anteprime trickplay</string> + <string name="dpad_seek_mode_none_summary">Nessuna barra di avanzamento</string> + <string name="hash_matches">Corrispondenze hash</string> + <string name="user_views">Viste utente</string> + <string name="audio_books">Audiolibri</string> + <string name="server_version_not_supported">Versione del server non supportata</string> + <string name="appears_on">Appare in</string> + <string name="disable_audio_offload">Disabilita offload hardware audio</string> + <string name="disable_audio_offload_summary">Disabilitare l’offload della decodifica audio può risolvere i blocchi di riproduzione su alcuni dispositivi</string> + <string name="dim_after_delay">Scurisci lo schermo dopo un ritardo</string> + <string name="dim_percentage">Percentuale di oscuramento</string> + <string name="dim_screen">Scurisci schermo</string> + <string name="photo_albums">Album fotografici</string> + <string name="no_favorites_found">Nessun preferito trovato!</string> + <string name="duration_hours_seconds">%1$do %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Stagione %1$d</string> + <string name="remove_from_playlist">Rimuovi dalla playlist</string> + <string name="login_credentials_expired">Credenziali scadute, effettua di nuovo l’accesso</string> + <string name="login_not_authorized">Non sei autorizzato ad accedere al server in questo momento</string> </resources> diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 9acfb772..a4191206 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -11,7 +11,7 @@ <string name="cancel_recording">בטל הקלטה</string> <string name="cancel_series_recording">בטל הקלטת סדרה</string> <string name="cancel">בטל</string> - <string name="chapters">פרקי משנה</string> + <string name="chapters">סצינות</string> <string name="clear_image_cache">נקה זיכרון מטמון</string> <string name="collection">אוסף</string> <string name="collections">אוספים</string> @@ -51,11 +51,10 @@ </plurals> <string name="tv_seasons">עונות</string> <string name="tv_season">עונה</string> - <string name="trailer">טריילר</string> <plurals name="trailers"> - <item quantity="one">טריילר</item> - <item quantity="two">טריילרים</item> - <item quantity="other">טריילרים</item> + <item quantity="one">קדימון</item> + <item quantity="two">קדימונים</item> + <item quantity="other">קדימונים</item> </plurals> <string name="subtitle">כתובית</string> <string name="subtitles">כתוביות</string> @@ -105,8 +104,8 @@ <string name="play">נגן</string> <string name="playback_speed">מהירות ניגון</string> <string name="playback">ניגון</string> - <string name="playlist">פלייליסט</string> - <string name="playlists">פלייליסטים</string> + <string name="playlist">רשימת־השמעה</string> + <string name="playlists">רשימות־השמעה</string> <plurals name="downloads"> <item quantity="one">הורדה %s</item> <item quantity="two">%s הורדות</item> @@ -141,7 +140,7 @@ <string name="mark_watched">סמן כנצפה</string> <string name="max_bitrate">מקסימום קצב נתונים</string> <string name="more_like_this">עוד דומים</string> - <string name="download_and_update"><![CDATA[הורד & עדכן]]></string> + <string name="download_and_update"><![CDATA[הורדה ועדכון]]></string> <string name="forced_track">כפוי</string> <string name="only_forced_subtitles">כתוביות כפויות בלבד</string> <string name="hide_controller_timeout">הסתר בקרי ניגון</string> @@ -172,7 +171,7 @@ <string name="restart">התחל מחדש</string> <string name="resume">המשך ניגון</string> <string name="save">שמור</string> - <string name="search_and_download"><![CDATA[חיפוש & הורדה]]></string> + <string name="search_and_download"><![CDATA[חיפוש והורדה]]></string> <string name="voice_search">חיפוש קולי</string> <string name="voice_search_prompt">חפש בקולך</string> <string name="processing">מעבד</string> @@ -216,13 +215,17 @@ <string name="video_scale">קנה מידה של הוידאו</string> <string name="video">וידאו</string> <string name="watch_live">צפה בשידור חי</string> - <string name="years_old">בגיל %1$d</string> + <plurals name="years_old"> + <item quantity="one">בגיל שנה</item> + <item quantity="two">בגיל שנתיים</item> + <item quantity="other">בגיל %1$d</item> + </plurals> <string name="play_with_transcoding">נגן עם המרת קידוד</string> <string name="media_information">פרטי המדיה</string> <string name="show_clock">הצג שעון</string> <string name="aired_episode_order">סדר שידור הפרקים</string> - <string name="create_playlist">צור פלייליסט חדש</string> - <string name="add_to_playlist">הוסף לפלייליסט</string> + <string name="create_playlist">צור רשימת־השמעה חדשה</string> + <string name="add_to_playlist">הוסף לרשימת־השמעה</string> <string name="one_click_pause">השהה בלחיצה אחת</string> <string name="one_click_pause_summary_on">לחץ על מרכז כפתור הניווט להשהייה/ניגון</string> <string name="italic_font">גופן נטוי</string> @@ -231,41 +234,63 @@ <string name="success">הצלחה</string> <string name="official_rating">דירוג הורים</string> <string name="runtime_sort">אורך</string> - <string name="extras">תכנים נוספים</string> + <string name="extras_title">תכנים נוספים</string> <plurals name="other_extras"> <item quantity="one">אחר</item> + <item quantity="two">אחרים</item> + <item quantity="other">אחרים</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">מאחורי הקלעים</item> + <item quantity="two">מאחורי הקלעים</item> + <item quantity="other">מאחורי הקלעים</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">שירי נושא</item> + <item quantity="one">שיר־נושא</item> + <item quantity="two">שירי־נושא</item> + <item quantity="other">שירי־נושא</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">סרטוני נושא</item> + <item quantity="one">סרטון־נושא</item> + <item quantity="two">סרטוני־נושא</item> + <item quantity="other">סרטוני־נושא</item> </plurals> <plurals name="clips"> - <item quantity="one">קליפים</item> + <item quantity="one">קליפ</item> + <item quantity="two">קליפים</item> + <item quantity="other">קליפים</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">סצינות שנמחקו</item> + <item quantity="one">סצינה גנוזה</item> + <item quantity="two">סצינות גנוזות</item> + <item quantity="other">סצינות גנוזות</item> </plurals> <plurals name="interviews"> - <item quantity="one">ראיונות</item> + <item quantity="one">ראיון</item> + <item quantity="two">ראיונות</item> + <item quantity="other">ראיונות</item> </plurals> <plurals name="scenes"> - <item quantity="one">סצינות</item> + <item quantity="one">סצינה</item> + <item quantity="two">סצינות</item> + <item quantity="other">סצינות</item> </plurals> <plurals name="samples"> - <item quantity="one">דגימות</item> + <item quantity="one">דגימה</item> + <item quantity="two">דגימות</item> + <item quantity="other">דגימות</item> </plurals> <plurals name="featurettes"> - <item quantity="one">סרטונים מיוחדים</item> + <item quantity="one">קטע־בונוס</item> + <item quantity="two">קטעי־בונוס</item> + <item quantity="other">קטעי־בונוס</item> </plurals> <plurals name="shorts"> - <item quantity="one">קטעים קצרים</item> + <item quantity="one">קטע קצר</item> + <item quantity="two">קטעים קצרים</item> + <item quantity="other">קטעים קצרים</item> </plurals> - <string name="favorite_items">מועדפים: %s</string> + <string name="favorite_items_title">מועדפים: %s</string> <string name="ac3_supported">המכשיר תומך ב־AC3/Dolby Digital</string> <string name="advanced_settings">הגדרות מתקדמות</string> <string name="advanced_ui">ממשק מתקדם</string> @@ -398,8 +423,8 @@ <string name="backdrop_display">סגנון תמונת־רקע</string> <string name="resolution_switching">החלפת רזולוציית־תצוגה</string> <string name="local">מקומי</string> - <string name="play_trailer">נגן טריילר</string> - <string name="no_trailers">אין טריילרים</string> + <string name="play_trailer">נגן קדימון</string> + <string name="no_trailers">אין קדימונים</string> <string name="clear_track_choices">ניקוי בחירת רצועות</string> <string name="force_dovi_profile_7">ניגון ישיר של Dolby Vision Profile 7</string> <string name="force_dovi_profile_7_summary">התעלם מבדיקות תאימות של המכשיר</string> @@ -608,9 +633,109 @@ <string name="live_tv_repeat">שידור חוזר</string> <string name="color_code_programs">קידוד צבע לתוכניות</string> <string name="slideshow_at_beginning">מההתחלה</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">תוצאות חיפוש ממוזגות</string> <string name="combined_search_results_on">הצג את כל התוצאות ברשימה אחת</string> <string name="combined_search_results_off">הצג תוצאות לפי קטגוריה</string> <string name="results">תוצאות</string> + <string name="require_login">דרוש אימות בכניסה לפרופיל</string> + <string name="show_voice_search_button">הצג כפתור לחיפוש קולי</string> + <string name="visible_ui">מוצג</string> + <string name="hidden_ui">חבוי</string> + <string name="view_options_type_grid">רשת</string> + <string name="view_options_type_list">רשימה</string> + <string name="view_options_type_list_compact">רשימה קומפקטית</string> + <string name="user_interface_language">שפת ממשק המשתמש</string> + <string name="display_toggles_title">תצוגת דירוגים</string> + <string name="display_toggles_summary">התאם אישית מה להציג</string> + <string name="profile_protection">אבטחת פרופיל</string> + <string name="continue_watching_click_behavior">התנהגות לחיצה על המשך צפייה / הבא בתור</string> + <string name="continue_watching_click_summary_on">נגן בלחיצה</string> + <string name="continue_watching_click_summary_off">הצג פרטים בלחיצה</string> + <string name="stars">כוכבים</string> + <string name="tomatoes">עגבניות</string> + <string name="sort">מיון</string> + <string name="split_into_separate_rows">פצל לשורות נפרדות</string> + <string name="movies_title">סרטים</string> + <string name="people_title">אנשים</string> + <string name="trailers_title">קדימונים</string> + <string name="tv_shows_title">סדרות</string> + <string name="other_extras_title">אחר</string> + <string name="behind_the_scenes_title">מאחורי הקלעים</string> + <string name="theme_songs_title">שירי־נושא</string> + <string name="theme_videos_title">סרטוני־נושא</string> + <string name="clips_title">קליפים</string> + <string name="deleted_scenes_title">סצינות גנוזות</string> + <string name="interviews_title">ראיונות</string> + <string name="scenes_title">סצינות</string> + <string name="samples_title">דגימות</string> + <string name="featurettes_title">קטעי בונוס</string> + <string name="shorts_title">קטעים קצרים</string> + <string name="duration_hours_minutes_seconds">%1$d שע\' %2$d דק\' %3$d שנ\'</string> + <string name="duration_hours_minutes">%1$d שע\' %2$d דק\'</string> + <string name="duration_minutes_seconds">%1$d דק\' %2$d שנ\'</string> + <string name="duration_hours">%1$d שע\'</string> + <string name="duration_minutes">%1$d דק\'</string> + <string name="duration_seconds">%1$d שנ\'</string> + <string name="time_left">%1$s נותרו</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[לקודד מחדש שמע שאינו סטריאו ל־AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">להשתמש ב־AC3 עבור שמע סראונד</string> + <string name="genre_movies">סרטי %1$s</string> + <string name="genre_tv_shows">סדרות %1$s</string> + <string name="quality_profile">פרופיל איכות</string> + <string name="is_default">ברירת מחדל</string> + <string name="root_folder">תיקייה ראשית</string> + <string name="popularity">פופולריות</string> + <string name="production_status">מצב הפקה</string> + <string name="different_for_hdr">השתמש בסגנון שונה עבור HDR</string> + <string name="experimental_settings">הגדרות ניסיוניות</string> + <string name="confirm_enable_experimental_title">האם לאפשר הגדרות ניסיוניות?</string> + <string name="confirm_enable_experimental_body">ההגדרות הניסיוניות עשויות להיות לא יציבות!\n\nהן עשויות להימחק או להשתנות בכל גרסה.</string> + <string name="unavailable">לא זמין</string> + <string name="blocklisted">ברשימה חסומה</string> + <string name="video_tunneling">מנהור וידאו</string> + <string name="brown">חום</string> + <string name="dpad_seek_mode_minimal">מינימלי</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="subtitle_mode">מצב כתוביות</string> + <string name="subtitle_mode_default">ברירת מחדל</string> + <string name="subtitle_mode_smart">חכם</string> + <string name="subtitle_mode_only_forced">כפויות בלבד</string> + <string name="subtitle_mode_always">תמיד</string> + <string name="subtitle_mode_none">ללא</string> + <string name="preferred_subtitle_language">שפה מועדפת לכתוביות</string> + <string name="preferred_audio_language">שפה מועדפת לשמע</string> + <string name="programs">תוכניות טלוויזיה</string> + <string name="recordings">הקלטות</string> + <string name="folders_title">תיקיות</string> + <string name="user_views">צפיות משתמש</string> + <string name="books">ספרים</string> + <string name="server_version_not_supported">גרסת השרת לא נתמכת</string> + <string name="profile_specific_settings_from_server">הגדרות פרופיל משתמש מהשרת</string> + <string name="d_pad_seek_mode_title">מצב פס הניגון של כפתור הניווט</string> + <string name="dpad_seek_mode_minimal_summary">פס ניגון קטן בתחתית המסך (ברירת מחדל)</string> + <string name="dpad_seek_mode_trickplay_summary">פס ניגון עם תצוגת תמונות Trickplay</string> + <string name="dpad_seek_mode_none_summary">ללא פס ניגון</string> + <string name="use_user_profile">השתמש בהגדרות הפרופיל שלך</string> + <string name="override_user_profile_settings">עוד הגדרות פרופיל משתמש</string> + <string name="override_user_profile_settings_summary">דרוס את הגדרות הפרופיל שלך מהשרת</string> + <string name="any_language">כל שפה</string> + <string name="subtitles_hearing_impaired">כתוביות לכבדי שמיעה</string> + <string name="audio_books">ספרי שמע</string> + <string name="hash_matches">התאמות של פונקציית־גיבוב</string> + <string name="appears_on">מופיע ב־</string> + <string name="disable_audio_offload_summary">השבתת פענוח אודיו בהורדה עשויה לתקן קפיאות בניגון במכשירים מסוימים</string> + <string name="disable_audio_offload">השבתת פענוח אודיו בהורדה</string> + <string name="dim_after_delay">עמעם תצוגה אחרי השהייה</string> + <string name="dim_percentage">עוצמת העמעום</string> + <string name="dim_screen">עמעם תצוגה</string> + <string name="photo_albums">אלבומי תמונות</string> + <string name="no_favorites_found">לא נמצאו מועדפים!</string> + <string name="duration_hours_seconds">%1$d שע\' %2$d שנ\'</string> + <string name="season_episode_number">עונה %1$d פרק %2$d</string> + <string name="season_episode_number_range">עונה %1$d פרקים %2$d-%3$d</string> + <string name="season_episode_number_padded">עונה %1$02d פרק %2$02d</string> + <string name="season_episode_number_padded_range">עונה %1$02d פרקים %2$02d-%3$02d</string> + <string name="episode_number_short">פרק %1$d</string> + <string name="season_number">עונה %1$d</string> </resources> diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index 43c74c5a..7b8e9032 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -6,8 +6,8 @@ <string name="bitrate">Aktum</string> <string name="cancel">Semmet</string> <string name="chapters">Ixfawen</string> - <string name="collection">Tagrumma</string> - <string name="collections">Tigrummiwin</string> + <string name="collection">Talkensit</string> + <string name="collections">Tilkensa</string> <string name="confirm">Serggeg</string> <string name="default_track">Amezwer</string> <string name="delete">Kkes</string> @@ -43,4 +43,14 @@ <string name="hdr10_plus">HDR10+</string> <string name="hlg">HLG</string> <string name="choose_stream">Fren %1$s</string> + <string name="more">Ugar</string> + <string name="name">Isem</string> + <string name="none">Ula yiwen</string> + <string name="subtitle">Aduzwel</string> + <string name="subtitles">Iduzwilen</string> + <string name="suggestions">Isumar</string> + <string name="settings">Iɣewwaren</string> + <string name="reset">Wennez</string> + <string name="remove">Kkes</string> + <string name="subtitle_margin">Tama</string> </resources> diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-lo/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index adb54e50..50efdb0c 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -31,7 +31,7 @@ <string name="director">Režisors</string> <string name="disabled">Atslēgts</string> <string name="discovered_servers">Atrastie serveri</string> - <string name="download_and_update"><![CDATA[Lejupielādēt & Atjaunināt]]></string> + <string name="download_and_update">Lejupielādēt & Atjaunināt</string> <string name="downloading">Lejupielādē…</string> <string name="enabled">Ieslēgts</string> <string name="ends_at">Beigsies %1$s</string> @@ -65,8 +65,7 @@ <string name="more">Vairāk</string> <plurals name="movies"> <item quantity="zero">Filmas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Filmas</item> </plurals> <string name="name">Nosaukums</string> <string name="next_up">Rindā</string> @@ -81,8 +80,7 @@ <string name="path">Ceļš</string> <plurals name="people"> <item quantity="zero">Cilvēki</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Cilvēki</item> </plurals> <string name="play_count">Skatījumu skaits</string> <string name="play_from_here">Atskaņot no šejienes</string> @@ -107,7 +105,7 @@ <string name="restart">Restartēt</string> <string name="resume">Turpināt</string> <string name="save">Saglabāt</string> - <string name="search_and_download"><![CDATA[Meklēt & Lejupielādēt]]></string> + <string name="search_and_download">Meklēt & Lejupielādēt</string> <string name="search">Meklēt</string> <string name="searching">Meklē…</string> <string name="voice_search">Balss meklēšana</string> @@ -150,11 +148,9 @@ <string name="switch_servers">Mainīt serverus</string> <string name="switch_user">Pārslēgt</string> <string name="top_unwatched">Augstāk novērtētie un neredzētie</string> - <string name="trailer">Treileris</string> <plurals name="trailers"> <item quantity="zero">Treileri</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Treileri</item> </plurals> <string name="tv_dvr_schedule">Ierakstu plāns</string> <string name="tv_guide">Programma</string> @@ -162,8 +158,7 @@ <string name="tv_seasons">Sezonas</string> <plurals name="tv_shows"> <item quantity="zero">TV Šovi</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">TV Šovi</item> </plurals> <string name="ui_interface">Saskarne</string> <string name="unknown">Nezināms</string> @@ -173,7 +168,9 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Skatīties tiešraidi</string> - <string name="years_old">%1$d gadus vecs</string> + <plurals name="years_old"> + <item quantity="other">%1$d gadus vecs</item> + </plurals> <string name="play_with_transcoding">Atskaņot ar transkodēšanu</string> <string name="media_information">Informācija par failu</string> <string name="show_clock">Rādīt Pulksteni</string> @@ -188,63 +185,52 @@ <string name="success">Veiksmīgi</string> <string name="official_rating">Vecuma ierobežojums</string> <string name="runtime_sort">Ilgums</string> - <string name="extras">Ekstras</string> + <string name="extras_title">Ekstras</string> <plurals name="other_extras"> <item quantity="zero">Cits</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Cits</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="zero">Aizkadri</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Aizkadri</item> </plurals> <plurals name="theme_songs"> <item quantity="zero">Tituldziesmas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Tituldziesmas</item> </plurals> <plurals name="theme_videos"> <item quantity="zero">Titulvideo</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Titulvideo</item> </plurals> <plurals name="clips"> <item quantity="zero">Klipi</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Klipi</item> </plurals> <plurals name="deleted_scenes"> <item quantity="zero">Izgrieztās ainas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Izgrieztās ainas</item> </plurals> <plurals name="interviews"> <item quantity="zero">Intervijas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Intervijas</item> </plurals> <plurals name="scenes"> <item quantity="zero">Ainas</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Ainas</item> </plurals> <plurals name="samples"> <item quantity="zero">Paraugi</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Paraugi</item> </plurals> <plurals name="featurettes"> <item quantity="zero">Īssižeti</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Īssižeti</item> </plurals> <plurals name="shorts"> <item quantity="zero">Īsie rullīši</item> - <item quantity="one"/> - <item quantity="other"/> + <item quantity="other">Īsie rullīši</item> </plurals> - <string name="favorite_items">Izlase %s</string> + <string name="favorite_items_title">Izlase %s</string> <plurals name="downloads"> <item quantity="zero">%s lejupielāžu</item> <item quantity="one">%s lejupielāde</item> diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index d892ebf3..9e26efcc 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -23,11 +23,11 @@ <string name="confirm">Bekreft</string> <string name="continue_watching">Fortsett å se</string> <string name="critic_rating">Kritikervurdering</string> - <string name="decimal_seconds">%.1f sekunder</string> + <string name="decimal_seconds">%.2f sekunder</string> <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Oppdagede servere</string> - <string name="download_and_update"><![CDATA[Last ned og oppdater]]></string> + <string name="download_and_update">Last ned og oppdater</string> <string name="downloading">Laster ned…</string> <string name="enabled">Aktivert</string> <string name="enter_server_url">Oppgi server-IP eller URL</string> @@ -59,7 +59,7 @@ <string name="max_bitrate">Maks bitrate</string> <string name="more_like_this">Mer som dette</string> <string name="more">Mer</string> - <string name="movies">Filmer</string> + <string name="movies_title">Filmer</string> <string name="name">Navn</string> <string name="next_up">Neste</string> <string name="no_data">Ingen data</string> @@ -70,7 +70,7 @@ <string name="none">Ingen</string> <string name="outro">Outro</string> <string name="path">Filbane</string> - <string name="people">Personer</string> + <string name="people_title">Personer</string> <string name="play_count">Antall avspillinger</string> <string name="play_from_here">Spill av herfra</string> <string name="play">Spill av</string> @@ -94,7 +94,7 @@ <string name="restart">Start på nytt</string> <string name="resume">Fortsett</string> <string name="save">Lagre</string> - <string name="search_and_download"><![CDATA[Søk og last ned]]></string> + <string name="search_and_download">Søk og last ned</string> <string name="search">Søk</string> <string name="searching">Søker…</string> <string name="select_server">Velg server</string> @@ -119,16 +119,15 @@ <string name="switch_servers">Bytt server</string> <string name="switch_user">Bytt bruker</string> <string name="top_unwatched">Best vurderte usette</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailere</item> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="other">Trailere</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> <string name="tv_season">Sesong</string> <string name="tv_seasons">Sesonger</string> - <string name="tv_shows">TV-serier</string> + <string name="tv_shows_title">TV-serier</string> <string name="ui_interface">Grensesnitt</string> <string name="unknown">Ukjent</string> <string name="updates">Oppdateringer</string> @@ -137,7 +136,10 @@ <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> + <plurals name="years_old"> + <item quantity="one">%1$d år gammel</item> + <item quantity="other">%1$d år gammel</item> + </plurals> <string name="play_with_transcoding">Spill av med transkoding</string> <string name="media_information">Mediainformasjon</string> <string name="show_clock">Vis klokke</string> @@ -152,50 +154,50 @@ <string name="success">Fullført</string> <string name="official_rating">Aldersgrense</string> <string name="runtime_sort">Spilletid</string> - <string name="extras">Ekstramateriale</string> + <string name="extras_title">Ekstramateriale</string> <plurals name="other_extras"> <item quantity="one">Annet</item> - <item quantity="other"/> + <item quantity="other">Annet</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissene</item> - <item quantity="other"/> + <item quantity="other">Bak kulissene</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Temasanger</item> - <item quantity="other"/> + <item quantity="one">Kjenningsmelodi</item> + <item quantity="other">Kjenningsmelodier</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Temavideoer</item> - <item quantity="other"/> + <item quantity="one">Kjenningsvideo</item> + <item quantity="other">Kjenningsvideoer</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"/> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Slettede scener</item> - <item quantity="other"/> + <item quantity="one">Slettet scene</item> + <item quantity="other">Slettede scener</item> </plurals> <plurals name="interviews"> - <item quantity="one">Intervjuer</item> - <item quantity="other"/> + <item quantity="one">Intervju</item> + <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="one">Scene</item> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> - <item quantity="one">Smakebiter</item> - <item quantity="other"/> + <item quantity="one">Smakebit</item> + <item quantity="other">Smakebiter</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Bakomfilmer</item> - <item quantity="other"/> + <item quantity="one">Novellefilm</item> + <item quantity="other">Novellefilmer</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilmer</item> - <item quantity="other"/> + <item quantity="one">Kortfilm</item> + <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedlasting</item> @@ -221,8 +223,8 @@ <string name="auto_play_next">Spill neste automatisk</string> <string name="check_for_updates">Sjekk etter oppdateringer</string> <string name="combine_continue_next_summary">Gjelder kun TV-serier</string> - <string name="combine_continue_next"><![CDATA[Slå sammen \'Fortsett å se\' og \'Neste\']]></string> - <string name="direct_play_ass">Direkteavspilling av ASS-undertekster</string> + <string name="combine_continue_next">Slå sammen \'Fortsett å se\' og \'Neste\'</string> + <string name="direct_play_ass">Bruk libass for ASS-undertekster</string> <string name="direct_play_pgs">Direkteavspilling av PGS-undertekster</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> <string name="ffmpeg_extension_pref">Bruk FFmpeg-dekodermodul</string> @@ -306,7 +308,7 @@ <string name="aspect_ratio">Bildeforhold</string> <string name="show_details">Vis detaljer</string> <string name="image_type">Bildetype</string> - <string name="cast_and_crew"><![CDATA[Skuespillere og medarbeidere]]></string> + <string name="cast_and_crew">Skuespillere og medarbeidere</string> <string name="guest_stars">Gjesteroller</string> <string name="edge_size">Kantstørrelse</string> <string name="refresh_rate_switching">Bytting av bildeoppdateringsfrekvens</string> @@ -364,4 +366,347 @@ <string name="play_trailer">Spill trailer</string> <string name="no_trailers">Ingen trailere</string> <string name="clear_track_choices">Fjern valg av spor</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dmin</string> + <string name="duration_minutes_seconds">%1$dmin %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dmin</string> + <string name="duration_seconds">%1$ds</string> + <string name="only_forced_subtitles">Bare Tvungne Undertekster</string> + <string name="profile_specific_settings_from_server">Brukerprofil instillinger fra server</string> + <string name="voice_search">Stemmesøk</string> + <string name="voice_starting">Begynner</string> + <string name="voice_search_prompt">Snakk for å søke</string> + <string name="processing">Behandler</string> + <string name="press_back_to_cancel">Trykk tilbake for å avbryte</string> + <string name="voice_error_audio">Lydopptaksfeil</string> + <string name="voice_error_client">Stemmegjenkjenningsfeil</string> + <string name="voice_error_permissions">Mikrofonadgang nødvendig</string> + <string name="voice_error_network">Nettverksfeil</string> + <string name="voice_error_network_timeout">Nettverksavbrudd</string> + <string name="voice_error_no_match">Ingen tale gjenkjent</string> + <string name="voice_error_busy">Stemmegjenkjenning opptatt</string> + <string name="voice_error_server">Tjenerfeil</string> + <string name="voice_error_speech_timeout">Ingen tale oppdaget</string> + <string name="voice_error_unknown">Ukjent feil</string> + <string name="voice_error_start_failed">Feil ved oppstart av stemmegjenkjenning</string> + <string name="voice_error_timeout">Tidsavbrudd ved stemmegjenkjenning</string> + <string name="retry">Prøv igjen</string> + <string name="time_left">%1$s gjenstår</string> + <string name="trailers_title">Trailere</string> + <string name="other_extras_title">Annet</string> + <string name="behind_the_scenes_title">Bak kulissene</string> + <string name="theme_songs_title">Kjenningsmelodier</string> + <string name="theme_videos_title">Kjenningsvideoer</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Slettede Scener</string> + <string name="interviews_title">Intervju</string> + <string name="scenes_title">Scener</string> + <string name="samples_title">Prøver</string> + <string name="featurettes_title">Novellefilmer</string> + <string name="shorts_title">Kortfilmer</string> + <string name="favorite_items_title">Favoritt %s</string> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dager</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minutt</item> + <item quantity="other">%s minutter</item> + </plurals> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="require_login">Krev innlogging for profil</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="disable_audio_offload">Deaktiver avlasting av lydmaskinvare</string> + <string name="disable_audio_offload_summary">Deaktivering av lyddekodingavlasting kan fikse fryst bilde ved avspilling på noen enheter</string> + <string name="combined_search_results">Kombinert søkeresultater</string> + <string name="combined_search_results_on">Vis alle resultater i én liste</string> + <string name="combined_search_results_off">Vis resultater etter kategori</string> + <string name="show_voice_search_button">Vis knapp for stemmesøk</string> + <string name="visible_ui">Synlig</string> + <string name="hidden_ui">Skjult</string> + <string name="results">Resultater</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</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">Direct play Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorer kompatibilitetsjekk av enheten</string> + <string name="discover">Oppdag</string> + <string name="request">Forespørsel</string> + <string name="pending">Venter</string> + <string name="seerr_integration">Seerr integrering</string> + <string name="remove_seerr_server">Fjern Seerr Tjener</string> + <string name="seerr_server_added">Seerr tjener lagt til</string> + <string name="quick_connect">Rask Tilkobling</string> + <string name="quick_connect_summary">Autoriser annen enhet til å logge på kontoen din</string> + <string name="for_episodes">For episoder</string> + <string name="suggestions_for">Forslag for %1$s</string> + <string name="increase_all_cards_size">Øk størrelsen for alle kort</string> + <string name="decrease_all_cards_size">Reduser størrelsen for alle kort</string> + <string name="use_thumb_images">Bruk miniatyrbilder</string> + <string name="settings_saved">Instillinger lagret</string> + <string name="display_presets">Vis forhåndsinnstillinger</string> + <string name="display_presets_description">Innebygde forhåndsinnstillinger for å raskt style alle rader</string> + <string name="customize_home_summary">Velg rader og bilder på hjemmesiden</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkode ikke-stereo lyd til AV3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Bruk AC3 for surround sound lyd</string> + <string name="start_after">Start etter</string> + <string name="animate">Animer</string> + <string name="max_age_rating">Maks aldersgrense</string> + <string name="no_max">Ingen maks</string> + <string name="for_all_ages">For alle aldre</string> + <string name="up_to_age">Opp til %s år</string> + <string name="screensaver">Skjermsparer</string> + <string name="screensaver_settings">Innstillinger for skjermsparer</string> + <string name="start_screensaver">Start skjermsparer</string> + <string name="duration">Varighet</string> + <string name="photos">Bilder</string> + <string name="include_types">Inkluder typer</string> + <string name="include_types_summary">Hvilke elementer å vise bilder for</string> + <string name="content_scale_fit">Tilpass</string> + <string name="content_scale_crop">Beskjær</string> + <string name="content_scale_fill">Fyll</string> + <string name="content_scale_fill_width">Fyll bredde</string> + <string name="content_scale_fill_height">Fyll høyde</string> + <string name="display_preset_default">Wholphin Standard</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_series_thumb">Serieminiatyrbilder</string> + <string name="display_preset_episode_thumbnails">Episodeminiatyrbilder</string> + <string name="volume_lowest">Lavest</string> + <string name="volume_low">Lav</string> + <string name="volume_medium">Middels</string> + <string name="volume_high">Høy</string> + <string name="volume_full">Full</string> + <string name="purple">Lilla</string> + <string name="orange">Oransje</string> + <string name="bold_blue">Dristig Blå</string> + <string name="black">Svart</string> + <string name="brown">Brun</string> + <string name="skip_ignore">Ignorer</string> + <string name="skip_automatically">Hopp over automatisk</string> + <string name="skip_ask">Spør om å hoppe over</string> + <string name="ffmpeg_fallback">Bare bruk FFmpeg dersom ingen innebygd dekoder eksisterer</string> + <string name="ffmpeg_prefer">Foretrekk å bruke FFmpeg over innebygde dekodere</string> + <string name="ffmpeg_never">Aldri bruk FFmpeg dekodere</string> + <string name="next_up_playback_end">Ved slutten av avspilling</string> + <string name="next_up_outro">Under rulletekst/avslutning</string> + <string name="next_up_never">Aldri</string> + <string name="white">Hvit</string> + <string name="light_gray">Lysegrå</string> + <string name="dark_gray">Mørkegrå</string> + <string name="yellow">Gul</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Kant</string> + <string name="subtitle_edge_shadow">Skygge</string> + <string name="background_style_wrap">Innpakning</string> + <string name="background_style_boxed">Innebokset</string> + <string name="prefer_mpv">Foretrekk MPV</string> + <string name="external_player">Ekstern Spiller</string> + <string name="player_backend_options_subtitles_prefer_mpv">Bruk ExoPlayer for avspilling av HDR</string> + <string name="aspect_ratios_poster">Plakat (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">Kvadrat (1:1)</string> + <string name="image_type_primary">Primær</string> + <string name="image_type_thumb">Miniatyr</string> + <string name="view_options_type_grid">Rutenett</string> + <string name="view_options_type_list">Liste</string> + <string name="view_options_type_list_compact">Kompakt Liste</string> + <string name="backdrop_style_dynamic">Bilde med dynamisk farge</string> + <string name="backdrop_style_image">Bare bilde</string> + <string name="enter_url_api_key"><![CDATA[Skriv inn URL & API-nøkkel]]></string> + <string name="api_key">API-nøkkel</string> + <string name="seerr_login">Logg inn på Seerr-tjener</string> + <string name="seerr_jellyfin_user">Jellyfinbruker</string> + <string name="seerr_local_user">Lokal bruker</string> + <string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekst]]></string> + <string name="no_subtitles_found">Ingen eksterne undertekster ble funnet</string> + <string name="series_continueing">Nåværende</string> + <string name="in_app_screensaver">Bruk appens skjermsparer</string> + <string name="delete_item">Er du sikker på at du vil slette dette elementet?</string> + <string name="show_media_management">Vis alternativ for behandling av medier</string> + <string name="search_for">Søk %s</string> + <string name="actor">Skuespiller</string> + <string name="composer">Komponist</string> + <string name="writer">Forfatter</string> + <string name="guest_star">Gjesteartist</string> + <string name="producer">Produsent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Sangtekstforfatter</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Ingeniør</string> + <string name="mixer">Lydmikser</string> + <string name="creator">Skaper</string> + <string name="artist">Artist</string> + <string name="album_artist">Albumartist</string> + <string name="album">Album</string> + <string name="popular_songs">Populære sanger</string> + <string name="play_next">Spill av neste</string> + <string name="music_videos">Musikkvideoer</string> + <string name="lyrics">Sangtekst</string> + <string name="hide_lyrics">Skjul sangtekst</string> + <string name="show_lyrics">Vis sangtekst</string> + <string name="song_has_lyrics">Låt har sangtekst</string> + <string name="remove_from_queue">Fjern fra kø</string> + <string name="show_album_cover">Vis albumomslag</string> + <string name="show_visualizer">Vis visualizer</string> + <string name="show_backdrop">Vis bakgrunn</string> + <string name="play_as_type">Spill av som?</string> + <string name="visualizer_rationale">Visualisering av lyden som spilles av, krever tillatelse til å ta opp lyd.</string> + <string name="continue_string">Fortsett</string> + <string name="select_all">Velg alle</string> + <string name="separate_types">Seperate typer</string> + <string name="prefer_logos">Foretrekk å vise logoer for titler</string> + <string name="updated_toast">Wholphin oppdatert til %s</string> + <string name="remove_continue_watching">Fjern fra fortsett å se</string> + <string name="remove_next_up">Fjern serier fra neste</string> + <string name="view_removed_next_up">Vis serier fjernet fra neste</string> + <string name="view_more">Vis mer</string> + <string name="discover_tv">Oppdag TV-serier</string> + <string name="discover_movies">Oppdag Filmer</string> + <string name="studios_in">Studioer i %1$s</string> + <string name="last_played">Sist avspilt</string> + <string name="system_default">System standard</string> + <string name="ass_subtitle_mode_libass">Direkteavspilling med libass</string> + <string name="ass_subtitle_mode_exoplayer">Direkteavspilling med ExoPlayer innebygd</string> + <string name="ass_subtitle_mode_transcode">Brenn/transkode på tjener</string> + <string name="ass_subtitle_playback">SSA/ASS avspilling av undertekst</string> + <string name="cinema_mode">Kinomodus</string> + <string name="show_more">Vis %1$s mer</string> + <string name="skip_behavior">Hopp over oppførsel</string> + <string name="skip_behavior_summary">For introer, rulletekst, etc</string> + <string name="play_with">Spill med</string> + <string name="transcoding">Transkoding</string> + <string name="user_interface_language">Språk på brukergrensesnitt</string> + <string name="display_toggles_title">Visning av anmeldelser</string> + <string name="display_toggles_summary">Tilpass hva å vise</string> + <string name="profile_protection">Profilbeskyttelse</string> + <string name="continue_watching_click_behavior">Fortsett å se/Neste klikkoppførsel</string> + <string name="continue_watching_click_summary_on">Spill av ved klikk</string> + <string name="continue_watching_click_summary_off">Vis detaljer ved klikk</string> + <string name="stars">Stjerner</string> + <string name="tomatoes">Tomater</string> + <string name="sort">Sorter</string> + <string name="split_into_separate_rows">Del inn i separate rader</string> + <string name="genre_movies">%1$s Filmer</string> + <string name="genre_tv_shows">%1$s TV-Serier</string> + <string name="quality_profile">Kvalitetsprofil</string> + <string name="is_default">Standard</string> + <string name="root_folder">Root-mappe</string> + <string name="popularity">Populæritet</string> + <string name="production_status">Produksjonstatus</string> + <string name="different_for_hdr">Bruk annen stil for HDR</string> + <string name="experimental_settings">Eksperimentelle innstillinger</string> + <string name="confirm_enable_experimental_title">Aktiver eksperimentelle innstillinger?</string> + <string name="confirm_enable_experimental_body">Eksperimentelle innstillinger kan være ustabile!\n\nDisse kan fjernes eller endre i hvilken som helst versjon.</string> + <string name="quick_connect_code">Skriv inn Quick Connect kode</string> + <string name="quick_connect_code_error">Kode må være 6 siffer</string> + <string name="quick_connect_success">Enhet autorisering vellykket</string> + <string name="password">Passord</string> + <string name="username">Brukernavn</string> + <string name="url">URL</string> + <string name="trending">Trender</string> + <string name="upcoming_movies">Kommende Filmer</string> + <string name="upcoming_tv">Kommende TV-Serier</string> + <string name="request_4k">Forespør i 4K</string> + <string name="software_decoding_av1">AV1 programvaredekoding</string> + <string name="upgrade_mpv_toast">MPV er nå standardspiller utenom for HDR.\nDu kan endre dette i innstillinger.</string> + <string name="hdr_subtitle_style">HDR undertekststil</string> + <string name="image_subtitle_opacity">Bildeundertekst ugjennomsiktighet</string> + <string name="brightness">Lysstyrke</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Metning</string> + <string name="hue">Tone</string> + <string name="red">Rød</string> + <string name="green">Grønn</string> + <string name="blue">Blå</string> + <string name="blur">Uklarhet</string> + <string name="save_for_album">Lagre for album</string> + <string name="play_slideshow">Start lysbildefremvisning</string> + <string name="stop_slideshow">Stans lysbildefremvisning</string> + <string name="slideshow_at_beginning">Ved start</string> + <string name="no_more_images">Ingen flere bilder</string> + <string name="rotate_left">Roter venstre</string> + <string name="rotate_right">Roter høyre</string> + <string name="zoom_in">Zoom inn</string> + <string name="zoom_out">Zoom ut</string> + <string name="slideshow_duration">Lysbildefremvisningens varighet</string> + <string name="play_videos_during_slideshow">Spill av video under lysbildefremvisning</string> + <string name="send_media_info_log_to_server">Send mediainformasjonslogg til tjener</string> + <string name="no_limit">Ingen grense</string> + <string name="max_days_next_up">Maks dager i Neste</string> + <string name="albums">Album</string> + <string name="artists">Artister</string> + <string name="songs">Sanger</string> + <string name="go_to_artist">Gå til artist</string> + <string name="now_playing">Spilles nå</string> + <string name="instant_mix">Øyeblikkelig miks</string> + <string name="go_to_album">Gå til album</string> + <string name="add_to_queue">Legg til i kø</string> + <string name="add_row">Legg til rad</string> + <string name="genres_in">Sjangre i %1$s</string> + <string name="recently_released_in">Nylig utgitt i %1$s</string> + <string name="height">Høyde</string> + <string name="apply_all_rows">Bruk på alle rader</string> + <string name="customize_home">Tilpass hjemmesiden</string> + <string name="home_rows">Hjemmerader</string> + <string name="load_from_server">Last inn fra tjeners brukerprofil</string> + <string name="save_to_server">Lagre på tjeners brukerprofil</string> + <string name="load_from_web_client">Last inn fra web-client</string> + <string name="use_series">Bruk seriebilde</string> + <string name="add_row_for">Legg til rad for %1$s</string> + <string name="overwrite_server_settings">Overskriv innstillinger på tjener?</string> + <string name="overwrite_local_settings">Overskriv lokale innstillinger?</string> + <string name="video_tunneling">Videotunnelering</string> + <string name="d_pad_seek_mode_title">D-Pad søkelinjemodus</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Lurespilling</string> + <string name="dpad_seek_mode_minimal_summary">Liten søkelinje langs bunnen av skjermen (standard)</string> + <string name="dpad_seek_mode_trickplay_summary">Søkelinje med lurespilling forhåndsvisningsbilde</string> + <string name="dpad_seek_mode_none_summary">Ingen søkelinje</string> + <string name="blocklisted">Blokkeringslistet</string> + <string name="unavailable">Utilgjengelig</string> + <string name="subtitle_mode">Undertekstmodus</string> + <string name="use_user_profile">Din brukerprofilinnstilling</string> + <string name="subtitle_mode_default">Standard</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Bare Tvunget</string> + <string name="subtitle_mode_always">Alltid</string> + <string name="subtitle_mode_none">Ingen</string> + <string name="preferred_subtitle_language">Foretrukket undertekstspråk</string> + <string name="preferred_audio_language">Foretrukket lydspråk</string> + <string name="override_user_profile_settings">Flere brukerprofilinnstillinger</string> + <string name="override_user_profile_settings_summary">Overskriv brukerprofilinnstillingene dine fra tjeneren</string> + <string name="any_language">Hvilket som helst språk</string> + <string name="hash_matches">Hash-matcher</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV-program</string> + <string name="recordings">Opptak</string> + <string name="folders_title">Mapper</string> + <string name="user_views">Brukervisninger</string> + <string name="books">Bøker</string> + <string name="audio_books">Lydbøker</string> + <string name="server_version_not_supported">Tjenerversjon ikke støttet</string> + <string name="appears_on">Opptrer på</string> + <string name="dim_after_delay">Dim skjerm etter forsinking</string> + <string name="dim_percentage">Dim prosent</string> + <string name="dim_screen">Dim skjerm</string> + <string name="photo_albums">Fotoalbum</string> + <string name="no_favorites_found">Ingen favoritter funnet!</string> </resources> diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1c0fdb70..2d6bd0c4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -42,8 +42,8 @@ <string name="file_size">Grootte</string> <string name="forced_track">Geforceerd</string> <string name="genres">Genres</string> - <string name="go_to_series">Ga naar serie</string> - <string name="go_to">Ga naar</string> + <string name="go_to_series">Naar seizoenen</string> + <string name="go_to">Naar inhoud</string> <string name="hide_controller_timeout">Afspeelbediening verbergen</string> <string name="hide_debug_info">Foutopsporingsinformatie verbergen</string> <string name="hide">Verbergen</string> @@ -62,7 +62,7 @@ <string name="max_bitrate">Max. bitsnelheid</string> <string name="more_like_this">Soortgelijk</string> <string name="more">Meer</string> - <string name="movies">Films</string> + <string name="movies_title">Films</string> <string name="name">Naam</string> <string name="next_up">Volgende</string> <string name="no_data">Geen informatie</string> @@ -72,7 +72,7 @@ <string name="none">Geen</string> <string name="outro">Aftiteling</string> <string name="path">Locatie</string> - <string name="people">Personen</string> + <string name="people_title">Personen</string> <string name="play_count">Aantal keer bekeken</string> <string name="play_from_here">Vanaf hier afspelen</string> <string name="play">Afspelen</string> @@ -121,7 +121,6 @@ <string name="switch_servers">Andere server kiezen</string> <string name="switch_user">Kiezen</string> <string name="top_unwatched">Best beoordeeld niet-bekeken</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailer</item> <item quantity="other">Trailers</item> @@ -130,7 +129,7 @@ <string name="tv_guide">Gids</string> <string name="tv_season">Seizoen</string> <string name="tv_seasons">Seizoenen</string> - <string name="tv_shows">Series</string> + <string name="tv_shows_title">Series</string> <string name="ui_interface">Vormgeving</string> <string name="unknown">Onbekend</string> <string name="updates">Updates</string> @@ -139,7 +138,9 @@ <string name="video">Video</string> <string name="videos">Video\'s</string> <string name="watch_live">Live kijken</string> - <string name="years_old">%1$d jaar oud</string> + <plurals name="years_old"> + <item quantity="other">%1$d jaar oud</item> + </plurals> <string name="play_with_transcoding">Afspelen met transcodering</string> <string name="show_clock">Klok tonen</string> <string name="aired_episode_order">Volgorde van uitgezonden afleveringen</string> @@ -153,50 +154,50 @@ <string name="success">Voltooid</string> <string name="official_rating">Kijkwijzer</string> <string name="runtime_sort">Duur</string> - <string name="extras">Extra\'s</string> + <string name="extras_title">Extra\'s</string> <plurals name="other_extras"> <item quantity="one">Overig</item> - <item quantity="other"/> + <item quantity="other">Overig</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Achter de schermen</item> - <item quantity="other"/> + <item quantity="other">Achter de schermen</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Titelmuziek</item> - <item quantity="other"/> + <item quantity="other">Titelmuziek</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Titelvideo\'s</item> - <item quantity="other"/> + <item quantity="one">Titelvideo</item> + <item quantity="other">Titelvideo\'s</item> </plurals> <plurals name="clips"> - <item quantity="one">Clips</item> - <item quantity="other"/> + <item quantity="one">Clip</item> + <item quantity="other">Clips</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Verwijderde scènes</item> - <item quantity="other"/> + <item quantity="one">Verwijderde scène</item> + <item quantity="other">Verwijderde scènes</item> </plurals> <plurals name="interviews"> - <item quantity="one">Interviews</item> - <item quantity="other"/> + <item quantity="one">Interview</item> + <item quantity="other">Interviews</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scènes</item> - <item quantity="other"/> + <item quantity="one">Scène</item> + <item quantity="other">Scènes</item> </plurals> <plurals name="samples"> - <item quantity="one">Voorvertoningen</item> - <item quantity="other"/> + <item quantity="one">Voorvertoning</item> + <item quantity="other">Voorvertoningen</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Featurettes</item> - <item quantity="other"/> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> </plurals> <plurals name="shorts"> - <item quantity="one">Shorts</item> - <item quantity="other"/> + <item quantity="one">Short</item> + <item quantity="other">Shorts</item> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -224,7 +225,7 @@ <string name="check_for_updates">Controleren op updates</string> <string name="combine_continue_next_summary">Alleen van toepassing op tv-series</string> <string name="combine_continue_next"><![CDATA[Hervatten en Volgende combineren]]></string> - <string name="direct_play_ass">ASS-ondertiteling automatisch tonen</string> + <string name="direct_play_ass">ASS-ondertiteling automatischh tonen</string> <string name="direct_play_pgs">PGS-ondertiteling automatisch tonen</string> <string name="downmix_stereo">Geluid altijd omzetten naar stereo</string> <string name="ffmpeg_extension_pref">FFmpeg-decodering gebruiken</string> @@ -362,7 +363,7 @@ <string name="zoom_out">Zoom uit</string> <string name="skip_ignore">Negeren</string> <string name="skip_ask">Vraag om overslaan</string> - <string name="favorite_items">Favoriete %s</string> + <string name="favorite_items_title">Favoriete %s</string> <string name="duration">Duur</string> <string name="add_row">Rij toevoegen</string> <string name="for_all_ages">Alle leeftijden</string> @@ -545,4 +546,120 @@ <string name="ass_subtitle_playback">SSA/ASS ondertiteling tonen</string> <string name="ass_subtitle_mode_libass">Direct play met libass</string> <string name="play_with">Afspelen met</string> + <string name="other_extras_title">Overig</string> + <string name="behind_the_scenes_title">Achter de schermen</string> + <string name="theme_songs_title">Themaliedjes</string> + <string name="theme_videos_title">Themavideo’s</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Verwijderde scènes</string> + <string name="samples_title">Voorbeelden</string> + <string name="featurettes_title">Korte documentaire</string> + <string name="shorts_title">Korte films</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Films</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serie</item> + <item quantity="other">Series</item> + </plurals> + <plurals name="people"> + <item quantity="one">Persoon</item> + <item quantity="other">Personen</item> + </plurals> + <string name="require_login">Aanmelding vereisen voor dit profiel</string> + <string name="combined_search_results">Gecombineerde zoekresultaten</string> + <string name="combined_search_results_on">Alle resultaten in één lijst weergeven</string> + <string name="combined_search_results_off">Resultaten per categorie weergeven</string> + <string name="show_voice_search_button">Knop voor gesproken zoekopdrachten weergeven</string> + <string name="visible_ui">Zichtbaar</string> + <string name="hidden_ui">Verborgen</string> + <string name="results">Resultaten</string> + <string name="force_dovi_profile_7">Direct afspelen Dolby Vision Profile 7</string> + <string name="quick_connect">Snel verbinden</string> + <string name="trending">Populair</string> + <string name="software_decoding_av1">AV1-softwaredecodering</string> + <string name="contrast">Contrast</string> + <string name="hue">Tint</string> + <string name="red">Rood</string> + <string name="green">Groen</string> + <string name="blue">Blauw</string> + <string name="blur">Vervaging</string> + <string name="go_to_artist">Ga naar artiest</string> + <string name="go_to_album">Ga naar album</string> + <string name="recently_released_in">Recent uitgebracht in %1$s</string> + <string name="height">Hoogte</string> + <string name="apply_all_rows">Op alle rijen toepassen</string> + <string name="load_from_server">Gebruikersprofiel van de server laden</string> + <string name="save_to_server">Gebruikersprofiel op de server opslaan</string> + <string name="load_from_web_client">Laad van de web client</string> + <string name="use_series">Afbeelding van de serie gebruiken</string> + <string name="add_row_for">Voeg rij toe voor %1$s</string> + <string name="overwrite_server_settings">Instellingen op de server overschrijven?</string> + <string name="overwrite_local_settings">Lokale Instellingen overschrijven?</string> + <string name="next_up_never">Nooit</string> + <string name="trailers_title">Trailers</string> + <string name="confirm_enable_experimental_body">Experimentele instellingen kunnen instabiel zijn!\n\nDeze instellingen kunnen in elke nieuwe versie worden gewijzigd of verwijderd.</string> + <string name="confirm_enable_experimental_title">Experimentele instellingen inschakelen?</string> + <string name="experimental_settings">Experimentele instellingen</string> + <string name="search_and_download_subtitles"><![CDATA[Zoek & download ondertiteling]]></string> + <string name="updated_toast">Wholphin is geüpdatet naar %s</string> + <string name="last_played">Laatst afgespeeld</string> + <string name="ass_subtitle_mode_exoplayer">Direct afspelen met de ingebouwde ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Ondertitels inbranden/transcoderen op de server</string> + <string name="cinema_mode">Bioscoop modus</string> + <string name="show_more">Laat %1$s meer zien</string> + <string name="transcoding">Transcoderen</string> + <string name="user_interface_language">Taal van de gebruikersinterface</string> + <string name="display_toggles_title">Beoordelingsweergave</string> + <string name="display_toggles_summary">Pas aan wat wordt weergegeven</string> + <string name="profile_protection">Profiel beveiliging</string> + <string name="continue_watching_click_behavior">Klikgedrag voor ‘Verder kijken’ en ‘Volgende aflevering’</string> + <string name="continue_watching_click_summary_on">Afspelen op klik</string> + <string name="continue_watching_click_summary_off">Laat details zien op klik</string> + <string name="stars">Sterren</string> + <string name="sort">Sorteren</string> + <string name="split_into_separate_rows">In afzonderlijke rijen weergeven</string> + <string name="genre_movies">%1$s Films</string> + <string name="genre_tv_shows">%1$s Series</string> + <string name="quality_profile">Kwaliteitsprofiel</string> + <string name="root_folder">Root map</string> + <string name="popularity">Populariteit</string> + <string name="production_status">Productie status</string> + <string name="different_for_hdr">Gebruik verschillende stylen voor HDR</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</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="url">URL</string> + <string name="albums">Albums</string> + <string name="instant_mix">Instant mix</string> + <string name="genres_in">Genres in %1$s</string> + <string name="magenta">Magenta</string> + <string name="background_style_wrap">Wrap</string> + <string name="background_style_boxed">Boxed</string> + <string name="external_player">Externe speler</string> + <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="view_options_type_grid">Raster</string> + <string name="view_options_type_list">Lijst</string> + <string name="view_options_type_list_compact">Compacte lijst</string> + <string name="enter_url_api_key"><![CDATA[Vul URL & API sleutel in]]></string> + <string name="api_key">API Sleutel</string> + <string name="mixer">Mixer</string> + <string name="album">Album</string> + <string name="studios_in">Studios in %1$s</string> + <string name="system_default">Systeemstandaard</string> + <string name="tomatoes">Tomatoes</string> + <string name="is_default">Standaard</string> + <string name="video_tunneling">Videotunneling</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scenes</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="dts_x">DTS:X</string> + <string name="override_user_profile_settings">Meer instellingen gebruikersprofiel</string> + <string name="d_pad_seek_mode_title">"D-pad zoekbalk"</string> </resources> diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index db921e51..55e08a8d 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -32,7 +32,7 @@ <string name="director">Regissør</string> <string name="disabled">Deaktivert</string> <string name="discovered_servers">Funne servarar</string> - <string name="download_and_update"><![CDATA[Last ned og oppdater]]></string> + <string name="download_and_update">Last ned og oppdater</string> <string name="downloading">Lastar ned…</string> <string name="enabled">Aktivert</string> <string name="enter_server_url">Oppgi servar-IP eller URL</string> @@ -64,7 +64,7 @@ <string name="max_bitrate">Maks bitrate</string> <string name="more_like_this">Meir som dette</string> <string name="more">Meir</string> - <string name="movies">Filmar</string> + <string name="movies_title">Filmar</string> <string name="name">Namn</string> <string name="next_up">Neste</string> <string name="no_data">Ingen data</string> @@ -74,7 +74,7 @@ <string name="no_update_available">Ingen oppdatering tilgjengeleg</string> <string name="outro">Outro</string> <string name="path">Filveg</string> - <string name="people">Folk</string> + <string name="people_title">Folk</string> <string name="play_count">Antall avspelingar</string> <string name="play_from_here">Spel av herfrå</string> <string name="play">Spel av</string> @@ -98,7 +98,7 @@ <string name="restart">Start på nytt</string> <string name="resume">Hald fram</string> <string name="save">Lagre</string> - <string name="search_and_download"><![CDATA[Søk og last ned]]></string> + <string name="search_and_download">Søk og last ned</string> <string name="search">Søk</string> <string name="searching">Søker…</string> <string name="select_server">Vel servar</string> @@ -122,16 +122,15 @@ <string name="switch_servers">Byt servar</string> <string name="switch_user">Byt brukar</string> <string name="top_unwatched">Best vurderte usette</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailerar</item> - <item quantity="other"/> + <item quantity="other">Trailerar</item> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> <string name="tv_season">Sesong</string> <string name="tv_seasons">Sesongar</string> - <string name="tv_shows">TV-seriar</string> + <string name="tv_shows_title">TV-seriar</string> <string name="ui_interface">Grensesnitt</string> <string name="unknown">Ukjend</string> <string name="updates">Oppdateringar</string> @@ -140,7 +139,9 @@ <string name="video">Video</string> <string name="videos">Videoar</string> <string name="watch_live">Sjå direkte</string> - <string name="years_old">%1$d år gammal</string> + <plurals name="years_old"> + <item quantity="other">%1$d år gammal</item> + </plurals> <string name="play_with_transcoding">Spel av med transkoding</string> <string name="media_information">Mediainformasjon</string> <string name="show_clock">Vis klokke</string> @@ -155,50 +156,50 @@ <string name="success">Fullført</string> <string name="official_rating">Aldersgrense</string> <string name="runtime_sort">Speltid</string> - <string name="extras">Ekstra</string> + <string name="extras_title">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Anna</item> - <item quantity="other"/> + <item quantity="other">Anna</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissane</item> - <item quantity="other"/> + <item quantity="other">Bak kulissane</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasongar</item> - <item quantity="other"/> + <item quantity="other">Temasongar</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoar</item> - <item quantity="other"/> + <item quantity="other">Temavideoar</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"/> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Sletta sener</item> - <item quantity="other"/> + <item quantity="other">Sletta sener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervju</item> - <item quantity="other"/> + <item quantity="other">Intervju</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Smakebitar</item> - <item quantity="other"/> + <item quantity="other">Smakebitar</item> </plurals> <plurals name="featurettes"> <item quantity="one">Bakomfilmar</item> - <item quantity="other"/> + <item quantity="other">Bakomfilmar</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmar</item> - <item quantity="other"/> + <item quantity="other">Kortfilmar</item> </plurals> <string name="advanced_settings">Avanserte innstillingar</string> <string name="advanced_ui">Avansert grensesnitt</string> @@ -208,7 +209,7 @@ <string name="auto_play_next">Spel neste automatisk</string> <string name="check_for_updates">Sjekk etter oppdateringar</string> <string name="combine_continue_next_summary">Gjeld berre TV-seriar</string> - <string name="combine_continue_next"><![CDATA[Slå saman \'Hald fram med å sjå\' og \'Neste\']]></string> + <string name="combine_continue_next">Slå saman \'Hald fram med å sjå\' og \'Neste\'</string> <string name="direct_play_ass">Direkteavspeling av ASS-undertekstar</string> <string name="direct_play_pgs">Direkteavspeling av PGS-undertekstar</string> <string name="downmix_stereo">Alltid nedmiks til stereo</string> @@ -292,7 +293,7 @@ <string name="aspect_ratio">Bileteforhold</string> <string name="show_details">Vis detaljar</string> <string name="image_type">Bilettype</string> - <string name="cast_and_crew"><![CDATA[Skodespelarar og medarbeidarar]]></string> + <string name="cast_and_crew">Skodespelarar og medarbeidarar</string> <string name="guest_stars">Gjesteroller</string> <string name="edge_size">Kantstorleik</string> <string name="refresh_rate_switching">Byting av bildeoppdateringsfrekvens</string> @@ -378,7 +379,7 @@ <string name="voice_error_start_failed">Klarte ikkje starta talegjenkjenning</string> <string name="voice_error_timeout">Tidsavbrot for talegjenkjenning</string> <string name="retry">Prøv på nytt</string> - <string name="favorite_items">Favoritt %s</string> + <string name="favorite_items_title">Favoritt %s</string> <string name="resolution_switching">Oppløysingsbyte</string> <string name="local">Lokal</string> <string name="play_trailer">Spel trailer</string> @@ -518,12 +519,12 @@ <string name="image_type_thumb">Miniatyr</string> <string name="backdrop_style_dynamic">Bilete med dynamisk farge</string> <string name="backdrop_style_image">Berre bilete</string> - <string name="enter_url_api_key"><![CDATA[Skriv inn URL og API-nøkkel]]></string> + <string name="enter_url_api_key">Skriv inn URL og API-nøkkel</string> <string name="api_key">API-nøkkel</string> <string name="seerr_login">Logg inn på Seerr-tenar</string> <string name="seerr_jellyfin_user">Jellyfin-brukar</string> <string name="seerr_local_user">Lokal brukar</string> - <string name="search_and_download_subtitles"><![CDATA[Søk og last ned undertekstar]]></string> + <string name="search_and_download_subtitles">Søk og last ned undertekstar</string> <string name="no_subtitles_found">Fann ingen eksterne undertekstar</string> <string name="series_continueing">No</string> <string name="in_app_screensaver">Bruk skjermsparar i appen</string> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 625a172b..deb42ecd 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,7 +20,7 @@ <string name="max_bitrate">Maksymalny bitrate</string> <string name="more_like_this">Podobne</string> <string name="more">Więcej</string> - <string name="movies">Filmy</string> + <string name="movies_title">Filmy</string> <string name="next_up">Następnie</string> <string name="no_data">Brak danych</string> <string name="no_results">Brak wyników</string> @@ -33,7 +33,7 @@ <string name="skip">Pomiń</string> <string name="sort_by_date_added">Data dodania</string> <string name="sort_by_date_episode_added">Data dodania odcinka</string> - <string name="tv_shows">Seriale</string> + <string name="tv_shows_title">Seriale</string> <string name="tv_season">Sezon</string> <string name="tv_seasons">Sezony</string> <string name="subtitle">Napisy</string> @@ -42,7 +42,6 @@ <string name="switch_servers">Zmień serwer</string> <string name="switch_user">Zmień</string> <string name="top_unwatched">Najlepiej oceniane nieobejrzane</string> - <string name="trailer">Zwiastun</string> <plurals name="trailers"> <item quantity="one">Zwiastun</item> <item quantity="few">Zwiastuny</item> @@ -121,16 +120,16 @@ <string name="one_click_pause">Pauza jednym kliknięciem</string> <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 quantity="many"/> - <item quantity="other"/> + <item quantity="one">Wywiad</item> + <item quantity="few">Wywiady</item> + <item quantity="many">Wywiadów</item> + <item quantity="other">Wywiadów</item> </plurals> <plurals name="scenes"> - <item quantity="one">Sceny</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scena</item> + <item quantity="few">Sceny</item> + <item quantity="many">Scen</item> + <item quantity="other">Scen</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Czołówka</item> @@ -261,18 +260,17 @@ <string name="video_scale">Skalowanie obrazu</string> <string name="font_opacity">Przezroczystość czcionki</string> <string name="update_url_summary">Adres URL do sprawdzenia aktualizacji</string> - <string name="people">Obsada</string> + <string name="people_title">Obsada</string> <string name="none">Brak</string> <string name="unknown">Nieznany</string> - <string name="years_old">%1$d lat temu</string> <string name="success">Powodzenie</string> <string name="official_rating">Ocena rodzicielska</string> - <string name="extras">Dodatki</string> + <string name="extras_title">Dodatki</string> <plurals name="other_extras"> - <item quantity="one">Inne</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Inny</item> + <item quantity="few">Inne</item> + <item quantity="many">Innych</item> + <item quantity="other">Innych</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Usunięta scena</item> @@ -433,7 +431,7 @@ <string name="seerr_login">Zaloguj się do Seerr</string> <string name="seerr_jellyfin_user">Użytkownik Jellyfin</string> <string name="seerr_local_user">Użytkownik lokalny</string> - <string name="favorite_items">Ulubione %s</string> + <string name="favorite_items_title">Ulubione %s</string> <plurals name="days"> <item quantity="one">%s dzień</item> <item quantity="few">%s dni</item> @@ -459,7 +457,7 @@ <string name="force_dovi_profile_7">Odtwarzanie bezpośrednie Dolby Vision Profile 7</string> <string name="force_dovi_profile_7_summary">Ignoruj sprawdzanie zgodności urządzenia</string> <string name="discover">Odkryj</string> - <string name="request">Prośby</string> + <string name="request">Poproś</string> <string name="pending">Oczekujące</string> <string name="seerr_integration">Integracja z Seerr</string> <string name="remove_seerr_server">Usuń serwer Seerr</string> @@ -474,7 +472,7 @@ <string name="voice_error_start_failed">Nie udało się uruchomić rozpoznawania głosu</string> <string name="voice_error_timeout">Przekroczono limit czasu rozpoznawania głosu</string> <plurals name="theme_songs"> - <item quantity="one">Piosenka tematyczne</item> + <item quantity="one">Piosenka tematyczna</item> <item quantity="few">Piosenki tematyczne</item> <item quantity="many">Piosenek tematycznych</item> <item quantity="other">Piosenek tematycznych</item> @@ -590,28 +588,28 @@ <string name="aired_episode_order">Kolejność emisji odcinków</string> <string name="runtime_sort">Czas trwania</string> <plurals name="clips"> - <item quantity="one">Klipy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Klip</item> + <item quantity="few">Klipy</item> + <item quantity="many">Klipów</item> + <item quantity="other">Klipów</item> </plurals> <plurals name="samples"> - <item quantity="one">Próbki</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Próbka</item> + <item quantity="few">Próbki</item> + <item quantity="many">Próbek</item> + <item quantity="other">Próbek</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Filmy krótkometrażowe</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Film krótkometrażowy</item> + <item quantity="few">Filmy krótkometrażowe</item> + <item quantity="many">Filmów krótkometrażowych</item> + <item quantity="other">Filmów krótkometrażowych</item> </plurals> <plurals name="shorts"> - <item quantity="one">Krótkie filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Krótki filmy</item> + <item quantity="few">Krótkie filmy</item> + <item quantity="many">Krótkich filmy</item> + <item quantity="other">Krótkich filmy</item> </plurals> <string name="aspect_ratio">Proporcje ekranu</string> <string name="guest_stars">Aktorzy gościnni</string> @@ -657,4 +655,110 @@ <string name="view_options_type_list_compact">Lista kompaktowa</string> <string name="user_interface_language">Język interfejsu</string> <string name="view_options_type_grid">Siatka</string> + <string name="sort">Sortuj</string> + <string name="split_into_separate_rows">Podziel na osobne rzędy</string> + <string name="trailers_title">Zwiastuny</string> + <string name="other_extras_title">Inne</string> + <string name="behind_the_scenes_title">Za kulisami</string> + <string name="theme_songs_title">Piosenki tematyczne</string> + <string name="theme_videos_title">Filmy tematyczne</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Usunięte sceny</string> + <string name="interviews_title">Wywiady</string> + <string name="scenes_title">Sceny</string> + <string name="samples_title">Próbki</string> + <string name="featurettes_title">Materiały dodatkowe</string> + <string name="shorts_title">Szorty</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filmy</item> + <item quantity="many">Filmów</item> + <item quantity="other">Filmów</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osoby</item> + <item quantity="many">Osób</item> + <item quantity="other">Osób</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Serial</item> + <item quantity="few">Seriale</item> + <item quantity="many">Serialów</item> + <item quantity="other">Serialów</item> + </plurals> + <string name="genre_movies">Filmy z gatunku: %1$s</string> + <string name="genre_tv_shows">Seriale z gatunku: %1$s</string> + <string name="quality_profile">Profil jakości</string> + <string name="is_default">Domyślny</string> + <string name="root_folder">Folder główny</string> + <string name="popularity">Popularność</string> + <string name="production_status">Status produkcji</string> + <string name="different_for_hdr">Użyj innych styli dla HDR</string> + <string name="experimental_settings">Ustawienia eksperymentalne</string> + <string name="confirm_enable_experimental_title">Włączyć ustawienia eksperymentalne?</string> + <string name="confirm_enable_experimental_body">Eksperymentalne ustawienia mogą powodować problemy!\n\nMogą być usunięte bądź zmienione w kolejnym wydaniu.</string> + <string name="video_tunneling">Tunelowanie wideo</string> + <string name="time_left">Pozostało %1$s</string> + <string name="duration_minutes">%1$d min.</string> + <string name="duration_seconds">%1$d sek.</string> + <string name="duration_hours">%1$d godz.</string> + <string name="duration_hours_minutes_seconds">%1$d godz. %2$d min. %3$d sek.</string> + <string name="duration_hours_minutes">%1$d godz. %2$d min.</string> + <string name="duration_minutes_seconds">%1$d min. %2$d sek.</string> + <string name="blocklisted">Na czarnej liście</string> + <string name="unavailable">Niedostępne</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transkoduj dźwięk nie-stereo do formatu AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Używaj dźwięku AC3 jako dźwięku przestrzennego</string> + <string name="profile_specific_settings_from_server">Ustawienia użytkownika z serwera</string> + <plurals name="years_old"> + <item quantity="one">%1$d rok</item> + <item quantity="few">%1$d lata</item> + <item quantity="many">%1$d lat</item> + <item quantity="other">%1$d lat</item> + </plurals> + <string name="subtitle_mode">Tryb napisów</string> + <string name="use_user_profile">Użyj ustawień użytkownika</string> + <string name="subtitle_mode_default">Domyślny</string> + <string name="subtitle_mode_smart">Inteligentny</string> + <string name="subtitle_mode_only_forced">Tylko wymuszone</string> + <string name="subtitle_mode_always">Wszystkie</string> + <string name="subtitle_mode_none">Brak</string> + <string name="preferred_subtitle_language">Preferowany język napisów</string> + <string name="preferred_audio_language">Preferowany język dźwięku</string> + <string name="override_user_profile_settings">Więcej ustawień użytkownika</string> + <string name="any_language">Dowolny język</string> + <string name="override_user_profile_settings_summary">Napisz swoje ustawienia użytkownika, ustawieniami z serwera</string> + <string name="brown">Brązowy</string> + <string name="d_pad_seek_mode_title">Tryb paska wyszukiwania D-Pad</string> + <string name="dpad_seek_mode_minimal">Minimalistyczny</string> + <string name="dpad_seek_mode_trickplay">Podgląd scen</string> + <string name="dpad_seek_mode_minimal_summary">Mały pasek przewijania u dołu ekranu (domyślnie)</string> + <string name="dpad_seek_mode_trickplay_summary">Pasek wyszukiwania z podglądem wideo</string> + <string name="dpad_seek_mode_none_summary">Brak paska przewijania</string> + <string name="hash_matches">Dopasowania hashy</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programy TV</string> + <string name="recordings">Nagrania</string> + <string name="folders_title">Foldery</string> + <string name="user_views">Widoki użytkownika</string> + <string name="books">Książki</string> + <string name="audio_books">Audiobooki</string> + <string name="server_version_not_supported">Wersja serwera nie jest wspierana</string> + <string name="appears_on">Wystąpił w</string> + <string name="dim_screen">Przyciemnienie ekranu</string> + <string name="dim_after_delay">Opóźnienie przyciemniana ekranu</string> + <string name="dim_percentage">Procent przyciemniane</string> + <string name="photo_albums">Albumy zdjęć</string> + <string name="no_favorites_found">Nic jeszcze nie polubiono!</string> + <string name="season_number">Sezon %1$d</string> + <string name="remove_from_playlist">Usuń z listy odtwarzania</string> + <string name="login_credentials_expired">Sesja wygasła, zaloguj się ponownie</string> + <string name="login_not_authorized">Nie jesteś upoważniony do dostępu do serwera w tym momencie</string> + <string name="episode_number_short">O%1$d</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d O%2$d</string> + <string name="season_episode_number_range">S%1$d O%2$d-%3$d</string> + <string name="season_episode_number_padded">S%1$02d O%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02d O%2$02d-%3$02d</string> </resources> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bf4a7c94..e42abda3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -149,12 +149,6 @@ <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">Trailer</item> - <item quantity="many">Trailers</item> - <item quantity="other">Trailers</item> - </plurals> <string name="tv_dvr_schedule">Agenda do DVR</string> <string name="tv_guide">Guia</string> <string name="tv_season">Temporada</string> @@ -172,7 +166,9 @@ <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> + <plurals name="years_old"> + <item quantity="other">%1$d anos de idade</item> + </plurals> <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> @@ -187,7 +183,7 @@ <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> + <string name="extras_title">Extras</string> <plurals name="other_extras"> <item quantity="one">Outro</item> <item quantity="many">Outros</item> @@ -238,7 +234,7 @@ <item quantity="many">Curtas</item> <item quantity="other">Curtas</item> </plurals> - <string name="favorite_items">Favoritar %s</string> + <string name="favorite_items_title">Favoritar %s</string> <plurals name="downloads"> <item quantity="one">%s download</item> <item quantity="many">%s downloads</item> @@ -567,20 +563,19 @@ <string name="mixer">Mixagem</string> <plurals name="featurettes"> <item quantity="one">Bastidores</item> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="other">Bastidores</item> </plurals> <plurals name="minutes"> <item quantity="one">%s minuto</item> <item quantity="many">%s minutos</item> <item quantity="other">%s minutos</item> </plurals> - <string name="enter_url_api_key"><![CDATA[Insira a URL e a chave de API]]></string> - <string name="search_and_download_subtitles"><![CDATA[Procurar e baixar legendas]]></string> - <string name="download_and_update"><![CDATA[Baixar e atualizar]]></string> - <string name="search_and_download"><![CDATA[Procurar e baixar]]></string> - <string name="combine_continue_next"><![CDATA[Combinar \"Continue Assistindo\" e \"A Seguir\"]]></string> - <string name="cast_and_crew"><![CDATA[Elenco e equipe]]></string> + <string name="enter_url_api_key">Insira a URL e a chave de API</string> + <string name="search_and_download_subtitles">Procurar e baixar legendas</string> + <string name="download_and_update">Baixar e atualizar</string> + <string name="search_and_download">Procurar e baixar</string> + <string name="combine_continue_next">Combinar \"Continue Assistindo\" e \"A Seguir\"</string> + <string name="cast_and_crew">Elenco e equipe</string> <string name="albums">Álbuns</string> <string name="artists">Artistas</string> <string name="songs">Músicas</string> @@ -630,4 +625,96 @@ <string name="skip_behavior_summary">Para introduções, créditos, e etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificação</string> + <string name="movies_title">Filmes</string> + <string name="people_title">Pessoas</string> + <string name="trailers_title">Trailers</string> + <string name="tv_shows_title">Programas de TV</string> + <string name="other_extras_title">Outro</string> + <string name="behind_the_scenes_title">Nos Bastidores</string> + <string name="theme_songs_title">Músicas Tema</string> + <string name="deleted_scenes_title">Cenas Deletadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Cenas</string> + <string name="samples_title">Amostras</string> + <string name="combined_search_results">Resultados de pesquisa combinados</string> + <string name="combined_search_results_on">Mostrar todos os resultados em lista única</string> + <string name="combined_search_results_off">Mostrar resultados por categoria</string> + <string name="show_voice_search_button">Mostrar botão de pesquisa por voz</string> + <string name="visible_ui">Visível</string> + <string name="hidden_ui">Oculto</string> + <string name="results">Resultados</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_grid">Grade</string> + <string name="view_options_type_list_compact">Lista Compacta</string> + <string name="theme_videos_title">Vídeos Tema</string> + <string name="clips_title">Clipes</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Configuração de Perfil de Usuário do servidor</string> + <string name="time_left">%1$s left</string> + <string name="featurettes_title">Conteúdos extras</string> + <string name="shorts_title">Curtos</string> + <plurals name="trailers"> + <item quantity="one">Trailer</item> + <item quantity="many">Trailers</item> + <item quantity="other">Trailers</item> + </plurals> + <string name="require_login">Exigir login para o perfil</string> + <string name="pin_digits_hint">0-9</string> + <string name="disable_audio_offload">Desativar aceleração de hardware de áudio</string> + <string name="disable_audio_offload_summary">Desativar a aceleração de hardware de áudio pode corrigir travamentos durante a reprodução em alguns dispositivos</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Usar AC3 para áudio surround</string> + <string name="brown">Marrom</string> + <string name="user_interface_language">Idioma de exibição</string> + <string name="display_toggles_title">Exibir classificações</string> + <string name="display_toggles_summary">Personalizar itens exibidos</string> + <string name="profile_protection">Proteção do perfil</string> + <string name="continue_watching_click_behavior">Clique em Continuar assistindo/Próximo</string> + <string name="continue_watching_click_summary_on">Reproduzir ao clicar</string> + <string name="continue_watching_click_summary_off">Mostrar detalhes ao clicar</string> + <string name="stars">Estrelas</string> + <string name="tomatoes">Tomates</string> + <string name="sort">Ordenar</string> + <string name="split_into_separate_rows">Dividir em linhas separadas</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Pasta raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Status de produção</string> + <string name="different_for_hdr">Usar estilo diferente para HDR</string> + <string name="experimental_settings">Configurações experimentais</string> + <string name="confirm_enable_experimental_title">Ativar configurações experimentais?</string> + <string name="confirm_enable_experimental_body">Configurações experimentais podem ser instáveis!\n\nElas podem ser removidas ou alteradas em qualquer versão.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="d_pad_seek_mode_title">Modo da barra de busca com D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Navegação rápida</string> + <string name="dpad_seek_mode_minimal_summary">Barra de progresso pequena na parte inferior da tela (padrão)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de progresso com imagens de pré-visualização do Trickplay</string> + <string name="dpad_seek_mode_none_summary">Sem barra de progresso</string> + <string name="blocklisted">Bloqueado</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legenda</string> + <string name="use_user_profile">Usar configuração do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Somente forçadas</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhuma</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma preferido do áudio</string> + <string name="override_user_profile_settings">Mais configurações do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as configurações do seu perfil definidas pelo servidor</string> + <string name="any_language">Qualquer idioma</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> </resources> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 59913f81..b95286c4 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -59,7 +59,7 @@ <string name="max_bitrate">Bitrate Máximo</string> <string name="more_like_this">Mais como isto</string> <string name="more">Mais</string> - <string name="movies">Filmes</string> + <string name="movies_title">Filmes</string> <string name="name">Nome</string> <string name="next_up">A Seguir</string> <string name="no_data">Sem dados</string> @@ -68,7 +68,7 @@ <string name="no_update_available">Nenhuma atualização disponível</string> <string name="none">Nenhum</string> <string name="outro">Créditos finais</string> - <string name="people">Pessoas</string> + <string name="people_title">Pessoas</string> <string name="play_count">Reproduções</string> <string name="play_from_here">Reproduzir a partir daqui</string> <string name="play">Reproduzir</string> @@ -119,7 +119,6 @@ <string name="switch_servers">Mudar Servidores</string> <string name="switch_user">Mudar</string> <string name="top_unwatched">Mais Votados Não Assistidos</string> - <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailer</item> <item quantity="many">Trailers</item> @@ -129,7 +128,7 @@ <string name="tv_guide">Guia</string> <string name="tv_season">Temporada</string> <string name="tv_seasons">Temporadas</string> - <string name="tv_shows">Séries TV</string> + <string name="tv_shows_title">Séries TV</string> <string name="ui_interface">Interface</string> <string name="unknown">Desconhecido</string> <string name="updates">Atualizações</string> @@ -138,7 +137,11 @@ <string name="video">Vídeo</string> <string name="videos">Vídeos</string> <string name="watch_live">Ver em directo</string> - <string name="years_old">%1$d anos</string> + <plurals name="years_old"> + <item quantity="one">%1$d ano</item> + <item quantity="many">%1$d anos</item> + <item quantity="other">%1$d anos</item> + </plurals> <string name="play_with_transcoding">Reproduzir com transcodificação</string> <string name="show_clock">Mostrar Relógio</string> <string name="aired_episode_order">Ordem de Emissão</string> @@ -152,7 +155,7 @@ <string name="success">Êxito</string> <string name="official_rating">Classificação Parental</string> <string name="runtime_sort">Duração</string> - <string name="extras">Extras</string> + <string name="extras_title">Extras</string> <plurals name="other_extras"> <item quantity="one">Outro</item> <item quantity="many">Outros</item> @@ -164,49 +167,49 @@ <item quantity="other">Nos Bastidores</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Músicas temáticas</item> - <item quantity="many">Músicas temáticas</item> - <item quantity="other">Músicas temáticas</item> + <item quantity="one">Tema de abertura</item> + <item quantity="many">Temas de abertura</item> + <item quantity="other">Temas de abertura</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Vídeos Temáticos</item> - <item quantity="many">Vídeos Temáticos</item> - <item quantity="other">Vídeos Temáticos</item> + <item quantity="one">Genérico</item> + <item quantity="many">Genéricos</item> + <item quantity="other">Genéricos</item> </plurals> <plurals name="clips"> - <item quantity="one">Clipes</item> + <item quantity="one">Clipe</item> <item quantity="many">Clipes</item> <item quantity="other">Clipes</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Cenas Excluídas</item> - <item quantity="many">Cenas Excluídas</item> - <item quantity="other">Cenas Excluídas</item> + <item quantity="one">Cena cortada</item> + <item quantity="many">Cenas cortadas</item> + <item quantity="other">Cenas cortadas</item> </plurals> <plurals name="interviews"> - <item quantity="one">Entrevistas</item> + <item quantity="one">Entrevista</item> <item quantity="many">Entrevistas</item> <item quantity="other">Entrevistas</item> </plurals> <plurals name="scenes"> - <item quantity="one">Cenas</item> + <item quantity="one">Cena</item> <item quantity="many">Cenas</item> <item quantity="other">Cenas</item> </plurals> <plurals name="samples"> - <item quantity="one">Amostras</item> + <item quantity="one">Amostra</item> <item quantity="many">Amostras</item> <item quantity="other">Amostras</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Curta-metragens</item> - <item quantity="many">Curta-metragens</item> - <item quantity="other">Curta-metragens</item> + <item quantity="one">Video promocional</item> + <item quantity="many">Videos promocionais</item> + <item quantity="other">Videos promocionais</item> </plurals> <plurals name="shorts"> - <item quantity="one">Curtos</item> - <item quantity="many">Curtos</item> - <item quantity="other">Curtos</item> + <item quantity="one">Curta</item> + <item quantity="many">Curtas</item> + <item quantity="other">Curtas</item> </plurals> <plurals name="downloads"> <item quantity="one">%s transferido</item> @@ -253,7 +256,7 @@ <string name="nav_drawer_switch_on_focus_summary_off">Clique para mudar de página</string> <string name="nav_drawer_switch_on_focus">Alternar páginas da barra de navegação ao focar</string> <string name="pass_out_protection">Protecção Contra Inatividade</string> - <string name="play_theme_music">Reproduzir música temática</string> + <string name="play_theme_music">Reproduzir tema de abertura</string> <string name="playback_overrides">Substituições de reprodução</string> <string name="remember_selected_tab">Lembrar separadores selecionados</string> <string name="rewatch_next_up">Activar a revisão em \'A seguir\'</string> @@ -480,7 +483,7 @@ <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> - <string name="favorite_items">Favoritos %s</string> + <string name="favorite_items_title">Favoritos %s</string> <plurals name="minutes"> <item quantity="one">%s minuto</item> <item quantity="many">%s minutos</item> @@ -618,9 +621,121 @@ <string name="skip_behavior_summary">Para intros, créditos, etc</string> <string name="play_with">Reproduzir com</string> <string name="transcoding">Transcodificar</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">Combinar resultados da pesquisa</string> <string name="combined_search_results_on">Mostrar todos os resultados numa única lista</string> <string name="combined_search_results_off">Mostrar resultados por categoria</string> <string name="results">Resultados</string> + <string name="require_login">Exigir início de sessão para o perfil</string> + <string name="show_voice_search_button">Mostrar botão de pesquisa por voz</string> + <string name="visible_ui">Visível</string> + <string name="hidden_ui">Escondido</string> + <string name="view_options_type_grid">Grelha</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Lista Compacta</string> + <string name="user_interface_language">Língua do interface</string> + <string name="display_toggles_title">Visualização de avaliações</string> + <string name="display_toggles_summary">Personalizar o que mostrar</string> + <string name="profile_protection">Protecção do perfil</string> + <string name="continue_watching_click_behavior">Comportamento ao clicar em «Continuar a ver»/«A Seguir»</string> + <string name="continue_watching_click_summary_on">Reproduzir ao clicar</string> + <string name="continue_watching_click_summary_off">Mostrar detalhes ao clicar</string> + <string name="stars">Estrelas</string> + <string name="tomatoes">Tomates</string> + <string name="sort">Ordenar</string> + <string name="split_into_separate_rows">Dividir em linhas separadas</string> + <string name="trailers_title">Trailers</string> + <string name="other_extras_title">Outro</string> + <string name="behind_the_scenes_title">Nos bastidores</string> + <string name="clips_title">Clipes</string> + <string name="deleted_scenes_title">Cenas cortadas</string> + <string name="interviews_title">Entrevistas</string> + <string name="scenes_title">Cenas</string> + <string name="samples_title">Amostras</string> + <string name="shorts_title">Curtas</string> + <string name="theme_videos_title">Genéricos</string> + <string name="theme_songs_title">Temas de abertura</string> + <plurals name="movies"> + <item quantity="one">Filme</item> + <item quantity="many">Filmes</item> + <item quantity="other">Filmes</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Série de TV</item> + <item quantity="many">Séries de TV</item> + <item quantity="other">Séries de TV</item> + </plurals> + <plurals name="people"> + <item quantity="one">Pessoa</item> + <item quantity="many">Pessoas</item> + <item quantity="other">Pessoas</item> + </plurals> + <string name="featurettes_title">Videos promocionais</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="profile_specific_settings_from_server">Definições do perfil do utilizador a partir do servidor</string> + <string name="time_left">%1$s restantes</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcodificar áudio não estéreo para AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Utilize o AC3 para áudio surround</string> + <string name="genre_movies">%1$s Filmes</string> + <string name="genre_tv_shows">%1$s Séries TV</string> + <string name="quality_profile">Perfil de qualidade</string> + <string name="is_default">Padrão</string> + <string name="root_folder">Passa raiz</string> + <string name="popularity">Popularidade</string> + <string name="production_status">Estado da produção</string> + <string name="different_for_hdr">Utilizar um estilo diferente para HDR</string> + <string name="experimental_settings">Definições experimentais</string> + <string name="confirm_enable_experimental_title">Ativar definições experimentais?</string> + <string name="confirm_enable_experimental_body">As definições experimentais podem ser instáveis!\n\nPodem ser removidas ou alteradas em qualquer atualização.</string> + <string name="video_tunneling">Tunelamento de vídeo</string> + <string name="blocklisted">Lista de bloqueio</string> + <string name="unavailable">Indisponível</string> + <string name="subtitle_mode">Modo de legendas</string> + <string name="use_user_profile">Utilize as definições do seu perfil</string> + <string name="subtitle_mode_default">Padrão</string> + <string name="subtitle_mode_smart">Inteligente</string> + <string name="subtitle_mode_only_forced">Apenas forçado</string> + <string name="subtitle_mode_always">Sempre</string> + <string name="subtitle_mode_none">Nenhum</string> + <string name="preferred_subtitle_language">Idioma preferido das legendas</string> + <string name="preferred_audio_language">Idioma de áudio preferido</string> + <string name="override_user_profile_settings">Mais definições do perfil</string> + <string name="override_user_profile_settings_summary">Substituir as definições do seu perfil a partir do servidor</string> + <string name="any_language">Qualquer idioma</string> + <string name="brown">Castanho</string> + <string name="d_pad_seek_mode_title">Modo de barra de busca do D-Pad</string> + <string name="dpad_seek_mode_minimal">Mínimo</string> + <string name="dpad_seek_mode_trickplay">Fotograma</string> + <string name="dpad_seek_mode_minimal_summary">Barra de pesquisa pequena na parte inferior do ecrã (por predefinição)</string> + <string name="dpad_seek_mode_trickplay_summary">Barra de pesquisa com imagens de pré-visualização de fotogramas</string> + <string name="dpad_seek_mode_none_summary">Sem barra de pesquisa</string> + <string name="hash_matches">Correspondências de hash</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programas de TV</string> + <string name="recordings">Gravações</string> + <string name="folders_title">Pastas</string> + <string name="user_views">Visualizações do utilizador</string> + <string name="books">Livros</string> + <string name="audio_books">Audiolivros</string> + <string name="server_version_not_supported">Versão do servidor não suportada</string> + <string name="appears_on">Aparece em</string> + <string name="disable_audio_offload">Desativar a processamento de áudio por hardware</string> + <string name="disable_audio_offload_summary">Desativar o processamento de áudio por hardware pode resolver os bloqueios de reprodução em alguns dispositivos</string> + <string name="dim_after_delay">Escurecimento do ecrã após tempo de inatividade</string> + <string name="dim_percentage">Percentagem do escurecimento</string> + <string name="dim_screen">Escurecer ecrã</string> + <string name="photo_albums">Álbum de fotos</string> + <string name="no_favorites_found">Não foram encontrados favoritos!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">T%1$d E%2$d</string> + <string name="season_episode_number_range">T%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">T%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">T%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Temporada %1$d</string> </resources> diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 46a38847..d6a2d917 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -42,8 +42,7 @@ <string name="no_update_available">Nu este disponibilă nicio actualizare</string> <plurals name="movies"> <item quantity="one">Filme</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Filme</item> </plurals> <string name="library">Librărie</string> <string name="go_to">Mergi la</string> @@ -55,8 +54,7 @@ <string name="mark_watched">Marchează ca vizionat</string> <plurals name="people"> <item quantity="one">Persoane</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Persoane</item> </plurals> <string name="play_count">Număr de redări</string> <string name="more">Mai mult</string> @@ -88,7 +86,7 @@ <string name="aspect_ratio">Rată de aspect</string> <string name="show_details">Înregistrare detaliată</string> <string name="image_type">Tip imagine</string> - <string name="cast_and_crew"><![CDATA[Distribuție și echipă]]></string> + <string name="cast_and_crew">Distribuție și echipă</string> <string name="guest_stars">Vedete invitate</string> <string name="level">Nivel</string> <string name="channels">Canale</string> @@ -173,7 +171,7 @@ <string name="api_key">Cheie API</string> <string name="seerr_login">Conectează-te la serverul Seerr</string> <string name="discovered_servers">Servere descoperite</string> - <string name="download_and_update"><![CDATA[Descarcă & Actualizează]]></string> + <string name="download_and_update">Descarcă & Actualizează</string> <string name="commercial">Reclamă</string> <string name="community_rating">Evaluarea comunității</string> <string name="compose_error_message_title">A apărut o eroare! Apasă butonul pentru a trimite jurnalele către serverul tău.</string> @@ -215,21 +213,21 @@ <string name="tv_seasons">Sezoane</string> <plurals name="tv_shows"> <item quantity="one">Seriale TV</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Seriale TV</item> </plurals> <string name="ui_interface">Interfață</string> <string name="unknown">Necunoscut</string> <string name="updates">Actualizări</string> <string name="version">Versiune</string> <string name="show_clock">Arată ceasul</string> - <string name="years_old">%1$d ani vechime</string> + <plurals name="years_old"> + <item quantity="other">%1$d ani vechime</item> + </plurals> <string name="tv_guide">Ghid</string> <string name="one_click_pause">Pauză cu un click</string> <plurals name="interviews"> <item quantity="one">Interviuri</item> - <item quantity="few"/> - <item quantity="other"/> + <item quantity="other">Interviuri</item> </plurals> <string name="background">Fundal</string> <plurals name="downloads"> @@ -297,7 +295,7 @@ <string name="skip_ask">Cere să sari peste</string> <string name="seerr_jellyfin_user">Utilizator Jellyfin</string> <string name="seerr_local_user">Utilizator local</string> - <string name="search_and_download_subtitles"><![CDATA[Caută & descarcă subtitrări]]></string> + <string name="search_and_download_subtitles">Caută & descarcă subtitrări</string> <string name="composer">Compozitor</string> <string name="writer">Scriitor</string> <string name="lyrics">Versuri</string> @@ -319,7 +317,7 @@ <string name="playback_debug_info">Afișează informații de depanare privind redarea</string> <string name="only_forced_subtitles">Doar subtitrări forțate</string> <string name="save">Salvează</string> - <string name="search_and_download"><![CDATA[Caută & Descarcă]]></string> + <string name="search_and_download">Caută & Descarcă</string> <string name="resume">Reia</string> <string name="voice_starting">Începe</string> <string name="voice_search_prompt">Vorbește pentru a căuta</string> @@ -329,8 +327,7 @@ <string name="top_unwatched">Cele mai bine cotate, nevizionate</string> <plurals name="trailers"> <item quantity="one">Trailere</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Trailere</item> </plurals> <string name="play_with_transcoding">Redă cu transcodare</string> <string name="media_information">Informații media</string> @@ -338,33 +335,28 @@ <string name="add_to_playlist">Adaugă în lista de redare</string> <plurals name="other_extras"> <item quantity="one">Altele</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Altele</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">În spatele scenelor</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">În spatele scenelor</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Melodii tematice</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Melodii tematice</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Scene șterse</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Scene șterse</item> </plurals> <string name="official_rating">Evaluare parentală</string> <string name="runtime_sort">Timp de execuție</string> - <string name="extras">Extra</string> + <string name="extras_title">Extra</string> <plurals name="clips"> <item quantity="one">Clipuri</item> - <item quantity="few"></item> - <item quantity="other"></item> + <item quantity="other">Clipuri</item> </plurals> - <string name="favorite_items">%s favorit</string> + <string name="favorite_items_title">%s favorit</string> <string name="app_theme">Temă aplicație</string> <string name="auto_check_for_updates">Verifică automat dacă există actualizări</string> <string name="ac3_supported">Dispozitivul acceptă formatele AC3/Dolby Digital</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f94fc36b..ba8bb7b3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -20,14 +20,14 @@ <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> <string name="directed_by">Режиссёр %1$s</string> <string name="director">Режиссёр</string> <string name="discovered_servers">Обнаруженные серверы</string> - <string name="download_and_update"><![CDATA[Загрузить и Обновить]]></string> + <string name="download_and_update">Загрузить и Обновить</string> <string name="downloading">Скачивание…</string> <string name="enter_server_url">Введите IP или URL сервера</string> <string name="episodes">Эпизоды</string> @@ -52,14 +52,14 @@ <string name="mark_watched">Отметить как просмотренное</string> <string name="max_bitrate">Максимальный битрейт</string> <string name="more_like_this">Похожее</string> - <string name="movies">Фильмы</string> + <string name="movies_title">Фильмы</string> <string name="name">Имя</string> <string name="next_up">Далее</string> <string name="no_data">Нет информации</string> <string name="no_results">Нет результатов</string> <string name="no_update_available">Нет доступных обновлений</string> <string name="path">Путь</string> - <string name="people">Люди</string> + <string name="people_title">Люди</string> <string name="playback_debug_info">Показать отладочную инфомацию воспроизведения</string> <string name="playback_speed">Скорость воспроизведения</string> <string name="playback">Воспроизведение</string> @@ -73,7 +73,7 @@ <string name="remove_favorite">Убрать из избранного</string> <string name="resume">Продолжить</string> <string name="save">Сохранить</string> - <string name="search_and_download"><![CDATA[Найти и скачать]]></string> + <string name="search_and_download">Найти и скачать</string> <string name="search">Найти</string> <string name="searching">Поиск…</string> <string name="select_server">Выберите сервер</string> @@ -95,7 +95,6 @@ <string name="switch_servers">Сменить сервер</string> <string name="switch_user">Сменить</string> <string name="top_unwatched">Лучшее непросмотренное</string> - <string name="trailer">Трейлер</string> <plurals name="trailers"> <item quantity="one">Трейлер</item> <item quantity="few">Трейлера</item> @@ -105,14 +104,13 @@ <string name="tv_guide">Телегид</string> <string name="tv_season">Сезон</string> <string name="tv_seasons">Сезоны</string> - <string name="tv_shows">Телешоу</string> + <string name="tv_shows_title">Телешоу</string> <string name="unknown">Неизвестно</string> <string name="updates">Обновления</string> <string name="version">Версия</string> <string name="video_scale">Масштаб видео</string> <string name="video">Видео</string> <string name="videos">Видео</string> - <string name="years_old">%1$d возраст</string> <string name="play_with_transcoding">Воспроизвести с перекодированием</string> <string name="show_clock">Показывать часы</string> <string name="create_playlist">Создать новый плейлист</string> @@ -124,18 +122,18 @@ <string name="success">Успех</string> <string name="official_rating">Возрастной рейтинг</string> <string name="runtime_sort">Продолжительность</string> - <string name="extras">Дополнительно</string> + <string name="extras_title">Дополнительно</string> <plurals name="other_extras"> - <item quantity="one">Другое</item> + <item quantity="one">Другой</item> <item quantity="few">Других</item> <item quantity="many">Других</item> <item quantity="other">Других</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">За кадром</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">За кадром</item> + <item quantity="many">За кадром</item> + <item quantity="other">За кадром</item> </plurals> <plurals name="clips"> <item quantity="one">Клип</item> @@ -203,7 +201,7 @@ <string name="watch_live">Смотреть прямой эфир</string> <string name="aired_episode_order">Порядок выхода эпизодов</string> <string name="reset">Сброс</string> - <string name="cast_and_crew"><![CDATA[Снимались и снимали]]></string> + <string name="cast_and_crew">Снимались и снимали</string> <string name="guest_stars">Приглашённые звёзды</string> <string name="aspect_ratio">Соотношение сторон</string> <string name="show_details">Показать подробности</string> @@ -275,8 +273,8 @@ <string name="auto_play_next_delay">Задержка перед воспроизведением следующей серии</string> <string name="auto_play_next">Автовоспроизведение следующей серии</string> <string name="combine_continue_next_summary">Применяется только к Телешоу</string> - <string name="combine_continue_next"><![CDATA[Объединить Продолжить просмотр и Далее]]></string> - <string name="direct_play_ass">Прямое воспроизведение ASS-субтитров</string> + <string name="combine_continue_next">Объединить Продолжить просмотр и Далее</string> + <string name="direct_play_ass">Использовать libaas для ASS-субтитров</string> <string name="direct_play_pgs">Прямое воспроизведение PGS-субтитров</string> <string name="font_size">Размер шрифта</string> <string name="font_color">Цвет щрифта</string> @@ -298,16 +296,16 @@ <item quantity="other">%d объектов</item> </plurals> <plurals name="samples"> - <item quantity="one">Отрывки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Образец</item> + <item quantity="few">Образца</item> + <item quantity="many">Образцов</item> + <item quantity="other">Образцов</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Дополнительные материалы</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Дополнительный материал</item> + <item quantity="few">Дополнительных материала</item> + <item quantity="many">Дополнительных материалов</item> + <item quantity="other">Дополнительных материалов</item> </plurals> <string name="player_backend">Выбор плеера</string> <string name="edge_size">Толщина обводки</string> @@ -315,16 +313,16 @@ <string name="max_homepage_items">Максимальное количество элементов в ряду на домашней странице</string> <string name="forced_track">Форсированные</string> <plurals name="theme_songs"> - <item quantity="one">Саундтреки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Тематическая песня</item> + <item quantity="few">Тематические песни</item> + <item quantity="many">Тематических песен</item> + <item quantity="other">Тематических песен</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Видеоролики</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Тематическое видео</item> + <item quantity="few">Тематических видео</item> + <item quantity="many">Тематических видео</item> + <item quantity="other">Тематических видео</item> </plurals> <string name="nav_drawer_pins">Настроить элементы боковой панели</string> <string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string> @@ -338,10 +336,10 @@ <string name="nav_drawer_switch_on_focus">Смена страниц по выделению</string> <string name="pass_out_protection">Остановка воспроизведения при бездействии</string> <plurals name="shorts"> - <item quantity="one">Короткометражки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Короткометражка</item> + <item quantity="few">Короткометражки</item> + <item quantity="many">Короткометражек</item> + <item quantity="other">Короткометражек</item> </plurals> <string name="ends_at">Конец в %1$s</string> <string name="enter_server_address">Введите адрес сервера</string> @@ -430,4 +428,106 @@ <string name="light_gray">Светло-серый</string> <string name="dark_gray">Темно-серый</string> <string name="yellow">Желтый</string> + <plurals name="movies"> + <item quantity="one">Фильм</item> + <item quantity="few">Фильма</item> + <item quantity="many">Фильмов</item> + <item quantity="other">Фильмов</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">Сериал</item> + <item quantity="few">Сериала</item> + <item quantity="many">Сериалов</item> + <item quantity="other">Сериалов</item> + </plurals> + <plurals name="people"> + <item quantity="one">Человек</item> + <item quantity="few">Человека</item> + <item quantity="many">Человек</item> + <item quantity="other">Человек</item> + </plurals> + <plurals name="years_old"> + <item quantity="one">%1$d год</item> + <item quantity="few">%1$d года</item> + <item quantity="many">%1$d лет</item> + <item quantity="other">%1$d лет</item> + </plurals> + <string name="profile_specific_settings_from_server">Использовать настройки профиля с сервера</string> + <string name="trailers_title">Трейлеры</string> + <string name="behind_the_scenes_title">За кадром</string> + <string name="theme_songs_title">Тематические Песни</string> + <string name="theme_videos_title">Тематические Видео</string> + <string name="clips_title">Клипы</string> + <string name="deleted_scenes_title">Удаленные Сцены</string> + <string name="interviews_title">Интервью</string> + <string name="scenes_title">Сцены</string> + <string name="require_login">Требуется логин для профиля</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="avc">AVC</string> + <string name="sdr">SDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="sample_rate_unit">Hz</string> + <string name="combined_search_results_on">Показать все результаты в одном списке</string> + <string name="combined_search_results_off">Показать результаты по категориям</string> + <string name="show_voice_search_button">Показать кнопку голосового ввода</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</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 Profile 7</string> + <string name="server_version_not_supported">Серверная версия не поддерживается</string> + <string name="favorite_items_title">Избранное %s</string> + <string name="disable_audio_offload_summary">Отключение выгрузки аудио-декодирования может исправить фризы воспроизведения на некоторых устройствах</string> + <string name="nal">NAL</string> + <string name="show_titles">Показывать названия</string> + <string name="live_tv_repeat">Повтор</string> + <string name="favorite_channels_at_beginning">Сначала показывать избранные каналы</string> + <string name="sort_channels_recently_watched">Сортировка каналов по недавно просмотренным</string> + <string name="backdrop_display">Стиль фона</string> + <string name="combined_search_results">Комбинированные результаты поиска</string> + <string name="visible_ui">Видимый</string> + <string name="hidden_ui">Скрытый</string> + <string name="results">Результаты</string> + <string name="resolution_switching">Переключение разрешения</string> + <string name="local">Локальный</string> + <string name="clear_track_choices">Очистить выбор дорожки</string> + <string name="force_dovi_profile_7_summary">Игнорировать проверки совместимости устройства</string> + <string name="discover">Обзор</string> + <string name="request">Запрос</string> + <string name="pending">В ожидании</string> + <string name="remove_seerr_server">Удалить сервер Seerr</string> + <string name="seerr_server_added">Сервер Seerr добавлен</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="url">URL</string> + <string name="trending">В тренде</string> + <string name="upcoming_tv">Ожидаемые сериалы</string> + <string name="upcoming_movies">Ожидаемые фильмы</string> + <string name="request_4k">Запросить в 4K</string> + <string name="software_decoding_av1">Программное декодирование AV1</string> + <string name="upgrade_mpv_toast">MPV сейчас плеер по умолчанию исключая HDR.\nВы можете изменить это в настройках.</string> + <string name="play_slideshow">Воспроизвести слайдшоу</string> + <string name="stop_slideshow">Остановить слайдшоу</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> + <string name="no_limit">Без ограничения</string> + <string name="hdr">HDR</string> + <string name="blur">Размытие</string> + <string name="layout">Расположение</string> + <string name="ffmpeg_never">Никогда не использовать FFmpeg</string> + <string name="ffmpeg_fallback">Использовать FFmpeg только, если не существует встроенных декодеров</string> </resources> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index ede412b5..a57d3311 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -61,7 +61,7 @@ <string name="max_bitrate">Maximálny bitrate</string> <string name="more_like_this">Navrhované</string> <string name="more">Viac</string> - <string name="movies">Filmy</string> + <string name="movies_title">Filmy</string> <string name="name">Meno</string> <string name="next_up">Nasleduje</string> <string name="no_data">Žiadne dáta</string> @@ -71,7 +71,7 @@ <string name="none">Žiadne</string> <string name="outro">Outro</string> <string name="path">Cesta</string> - <string name="people">Ľudia</string> + <string name="people_title">Ľudia</string> <string name="play_count">Počet prehrávaní</string> <string name="play_from_here">Prehrať odtiaľto</string> <string name="play">Prehrať</string> @@ -143,22 +143,21 @@ <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 quantity="many"/> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="few">Trailery</item> + <item quantity="many">Trailerov</item> + <item quantity="other">Trailerov</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 quantity="many"/> - <item quantity="other"/> + <item quantity="one">Seriál</item> + <item quantity="few">Seriály</item> + <item quantity="many">Seriálov</item> + <item quantity="other">Seriálov</item> </plurals> <string name="ui_interface">Rozhranie</string> <string name="unknown">Neznámy</string> @@ -168,7 +167,12 @@ <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> + <plurals name="years_old"> + <item quantity="one">vo veku %1$d roka</item> + <item quantity="few">vo veku %1$d rokov</item> + <item quantity="many">vo veku %1$d rokov</item> + <item quantity="other">vo veku %1$d rokov</item> + </plurals> <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> @@ -183,74 +187,74 @@ <string name="success">Úspech</string> <string name="official_rating">Rodičovské hodnotenie</string> <string name="runtime_sort">Dĺžka</string> - <string name="extras">Bonusový materiál</string> + <string name="extras_title">Bonusový materiál</string> <plurals name="other_extras"> - <item quantity="one">Ostatné</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Iné</item> + <item quantity="few">Iné</item> + <item quantity="many">Iných</item> + <item quantity="other">Iných</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Zo zákulisia</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Zo zákulisia</item> + <item quantity="many">Zo zákulisia</item> + <item quantity="other">Zo zákulisia</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Tématické piesne</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Tématická pieseň</item> + <item quantity="few">Tématické piesne</item> + <item quantity="many">Tématických piesní</item> + <item quantity="other">Tématických piesní</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Tématické videá</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Tématické video</item> + <item quantity="few">Tématické videá</item> + <item quantity="many">Tématických videí</item> + <item quantity="other">Tématických videí</item> </plurals> <plurals name="clips"> - <item quantity="one">Klipy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Klip</item> + <item quantity="few">Klipy</item> + <item quantity="many">Klipov</item> + <item quantity="other">Klipov</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Vymazané scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Vymazaná scéna</item> + <item quantity="few">Vymazané scény</item> + <item quantity="many">Vymazaných scén</item> + <item quantity="other">Vymazaných scén</item> </plurals> <plurals name="interviews"> - <item quantity="one">Rozhovory</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Rozhovor</item> + <item quantity="few">Rozhovory</item> + <item quantity="many">Rozhovorov</item> + <item quantity="other">Rozhovorov</item> </plurals> <plurals name="scenes"> - <item quantity="one">Scény</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Scéna</item> + <item quantity="few">Scény</item> + <item quantity="many">Scén</item> + <item quantity="other">Scén</item> </plurals> <plurals name="samples"> - <item quantity="one">Vzorky</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Vzorka</item> + <item quantity="few">Vzorky</item> + <item quantity="many">Vzoriek</item> + <item quantity="other">Vzoriek</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Krátkometrážne filmy</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Krátkometrážny film</item> + <item quantity="few">Krátkometrážne filmy</item> + <item quantity="many">Krátkometrážnych filmov</item> + <item quantity="other">Krátkometrážnych filmov</item> </plurals> <plurals name="shorts"> - <item quantity="one">Krátke videá</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Krátke video</item> + <item quantity="few">Krátke videá</item> + <item quantity="many">Krátkych videí</item> + <item quantity="other">Krátkych videí</item> </plurals> - <string name="favorite_items">Obľúbené %s</string> + <string name="favorite_items_title">Obľúbené %s</string> <plurals name="downloads"> <item quantity="one">%s stiahnutý</item> <item quantity="few">%s stiahnuté</item> @@ -317,7 +321,7 @@ </plurals> <string name="auto_play_next_delay">Oneskorenie pred prehrávaním nasledujúceho</string> <string name="auto_play_next">Automatické prehrávanie nasledujúceho</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Spojiť sekcie Pokračovať v pozeraní a Nasleduje]]></string> <string name="playback_overrides">Prepísanie prehrávania</string> <string name="rewatch_next_up">Povoliť opätovné prehrávanie v nasledujúcich</string> <string name="skip_back_on_resume_preference">Pri obnovení prehrávania preskočiť späť</string> @@ -609,4 +613,151 @@ <string name="play_next">Prehrať nasledujúce</string> <string name="music_videos">Hudobné videá</string> <string name="lyrics">Texty piesní</string> + <string name="trailers_title">Trailery</string> + <string name="tv_shows_title">Seriály</string> + <string name="other_extras_title">Ostatné</string> + <string name="behind_the_scenes_title">Zo zákulisia</string> + <string name="theme_songs_title">Úvodné piesne</string> + <string name="theme_videos_title">Úvodné videá</string> + <string name="clips_title">Klipy</string> + <string name="deleted_scenes_title">Vystrihnuté scény</string> + <string name="interviews_title">Rozhovory</string> + <string name="scenes_title">Scény</string> + <string name="samples_title">Ukážky</string> + <string name="featurettes_title">Bonusy</string> + <string name="shorts_title">Krátke filmy</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="few">Filmy</item> + <item quantity="many">Filmov</item> + <item quantity="other">Filmov</item> + </plurals> + <plurals name="people"> + <item quantity="one">Osoba</item> + <item quantity="few">Osoby</item> + <item quantity="many">Osôb</item> + <item quantity="other">Osôb</item> + </plurals> + <string name="require_login">Vyžadovať prihlásenie pre profil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="combined_search_results">Kombinované výsledky vyhľadávania</string> + <string name="combined_search_results_on">Zobraziť všetky výsledky v jednom zozname</string> + <string name="combined_search_results_off">Zobraziť výsledky podľa kategórie</string> + <string name="show_voice_search_button">Zobraziť tlačidlo hlasového vyhľadávania</string> + <string name="visible_ui">Viditeľné</string> + <string name="hidden_ui">Skryté</string> + <string name="results">Výsledky</string> + <string name="view_options_type_grid">Mriežka</string> + <string name="view_options_type_list">Zoznam</string> + <string name="view_options_type_list_compact">Kompaktný zoznam</string> + <string name="hide_lyrics">Skryť text piesne</string> + <string name="show_lyrics">Zobraziť text piesne</string> + <string name="song_has_lyrics">Skladba má text piesne</string> + <string name="remove_from_queue">Odstrániť z fronty</string> + <string name="show_album_cover">Zobraziť obal albumu</string> + <string name="show_visualizer">Zobraziť vizualizér</string> + <string name="show_backdrop">Zobraziť pozadie</string> + <string name="play_as_type">Prehrať ako?</string> + <string name="visualizer_rationale">Na vizualizáciu práve prehrávaného zvuku je potrebné povolenie na nahrávanie zvuku.</string> + <string name="continue_string">Pokračovať</string> + <string name="separate_types">Oddeliť typy</string> + <string name="prefer_logos">Uprednostniť zobrazovanie log namiesto názvov</string> + <string name="updated_toast">Wholphin bol aktualizovaný na verziu %s</string> + <string name="remove_continue_watching">Odstrániť z Pokračovať v pozeraní</string> + <string name="remove_next_up">Odstrániť seriál zo sekcie Nasleduje</string> + <string name="view_removed_next_up">Zobraziť seriály odstránené zo sekcie Nasleduje</string> + <string name="view_more">Zobraziť viac</string> + <string name="discover_tv">Objavujte seriály</string> + <string name="discover_movies">Objavujte filmy</string> + <string name="studios_in">Štúdiá v %1$s</string> + <string name="last_played">Naposledy prehrané</string> + <string name="system_default">Predvolené nastavenie systému</string> + <string name="ass_subtitle_mode_libass">Priame prehrávanie s libass</string> + <string name="ass_subtitle_mode_exoplayer">Priame prehrávanie s vstavaným ExoPlayerom</string> + <string name="ass_subtitle_mode_transcode">Vypáliť titulky/prekódovať na serveri</string> + <string name="ass_subtitle_playback">Prehrávanie titulkov SSA/ASS</string> + <string name="cinema_mode">Kino režim</string> + <string name="show_more">Zobraziť ďalších %1$s</string> + <string name="skip_behavior">Správanie pri preskočení</string> + <string name="skip_behavior_summary">Pre intrá, titulky a podobne</string> + <string name="play_with">Prehrať pomocou</string> + <string name="transcoding">Prekódovanie</string> + <string name="user_interface_language">Jazyk používateľského rozhrania</string> + <string name="display_toggles_title">Zobrazenie hodnotení</string> + <string name="display_toggles_summary">Prispôsobte, čo sa má zobrazovať</string> + <string name="profile_protection">Ochrana profilu</string> + <string name="continue_watching_click_behavior">Správanie pri kliknutí v sekciách Pokračovať v pozeraní/Nasleduje</string> + <string name="continue_watching_click_summary_on">Prehrať po kliknutí</string> + <string name="continue_watching_click_summary_off">Zobraziť podrobnosti po kliknutí</string> + <string name="stars">Hviezdičky</string> + <string name="tomatoes">Paradajky</string> + <string name="sort">Zoradiť</string> + <string name="split_into_separate_rows">Rozdeliť do samostatných riadkov</string> + <string name="genre_movies">%1$s Filmov</string> + <string name="genre_tv_shows">%1$s Seriálov</string> + <string name="quality_profile">Profil kvality</string> + <string name="is_default">Predvolené</string> + <string name="root_folder">Koreňový priečinok</string> + <string name="popularity">Popularita</string> + <string name="production_status">Stav produkcie</string> + <string name="different_for_hdr">Použiť iný štýl pre HDR</string> + <string name="experimental_settings">Experimentálne nastavenia</string> + <string name="confirm_enable_experimental_title">Povoliť experimentálne nastavenia?</string> + <string name="confirm_enable_experimental_body">Experimentálne nastavenia môžu byť nestabilné!\n\n Môžu byť zmenené alebo odstránené v budúcich verziách.</string> + <string name="video_tunneling">Video tunneling</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">zostáva %1$s</string> + <string name="blocklisted">Blokované</string> + <string name="unavailable">Nedostupné</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Prekódovať viackanálový zvuk na AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Použiť AC3 pre viackanálový zvuk</string> + <string name="profile_specific_settings_from_server">Nastavenia používateľského profilu zo servera</string> + <string name="subtitle_mode">Režim titulkov</string> + <string name="use_user_profile">Použiť nastavenie profilu</string> + <string name="subtitle_mode_default">Predvolené</string> + <string name="subtitle_mode_smart">Inteligentné</string> + <string name="subtitle_mode_only_forced">Len vynútené</string> + <string name="subtitle_mode_always">Vždy</string> + <string name="subtitle_mode_none">Žiadne</string> + <string name="preferred_subtitle_language">Preferovaný jazyk titulkov</string> + <string name="preferred_audio_language">Preferovaný jazyk zvuku</string> + <string name="override_user_profile_settings">Viac nastavení používateľského profilu</string> + <string name="override_user_profile_settings_summary">Prepísať nastavenia používateľského profilu zo servera</string> + <string name="any_language">Akýkoľvek jazyk</string> + <string name="d_pad_seek_mode_title">Režim panela pretáčania pomocou D-Padu</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="hash_matches">Hash zhody</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programy</string> + <string name="recordings">Nahrávky</string> + <string name="folders_title">Priečinky</string> + <string name="user_views">Používateľské zobrazenia</string> + <string name="books">Knihy</string> + <string name="audio_books">Audioknihy</string> + <string name="brown">Hnedá</string> + <string name="dpad_seek_mode_minimal">Minimálne</string> + <string name="dpad_seek_mode_minimal_summary">Malý panel pretáčania v dolnej časti obrazovky (predvolené)</string> + <string name="dpad_seek_mode_trickplay_summary">Panel pretáčania s náhľadmi Trickplay</string> + <string name="dpad_seek_mode_none_summary">Bez panela pretáčania</string> + <string name="server_version_not_supported">Verzia servera nie je podporovaná</string> + <string name="appears_on">Objavuje sa v</string> + <string name="disable_audio_offload">Zakázať hardvérové odovzdávanie zvuku</string> + <string name="disable_audio_offload_summary">Zakázanie odovzdávania dekódovania zvuku môže vyriešiť zamŕzanie prehrávania na niektorých zariadeniach</string> + <string name="dim_after_delay">Stlmiť obrazovku po uplynutí času</string> + <string name="dim_percentage">Úroveň stlmenia</string> + <string name="dim_screen">Stlmenie obrazovky</string> + <string name="photo_albums">Fotoalbumy</string> + <string name="no_favorites_found">Nenašli sa žiadne obľúbené položky!</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Séria %1$d</string> </resources> diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 71e1bc71..46ec0b68 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -31,7 +31,7 @@ <string name="director">Režiser</string> <string name="disabled">Onemogočeno</string> <string name="discovered_servers">Najdeni strežniki</string> - <string name="download_and_update"><![CDATA[Prenesi in posodobi]]></string> + <string name="download_and_update">Prenesi in posodobi</string> <string name="downloading">Prenos poteka…</string> <string name="enabled">Omogočeno</string> <string name="ends_at">Konča se ob %1$s</string> @@ -66,6 +66,7 @@ <string name="more">Več</string> <plurals name="movies"> <item quantity="one">Filmi</item> + <item quantity="other">Filmi</item> </plurals> <string name="name">Ime</string> <string name="next_up">Naslednjo</string> @@ -80,6 +81,7 @@ <string name="path">Pot</string> <plurals name="people"> <item quantity="one">Osebe</item> + <item quantity="other">Osebe</item> </plurals> <string name="play_count">Število predvajanj</string> <string name="play_from_here">Predvajaj od tu</string> @@ -104,7 +106,7 @@ <string name="restart">Zaženi znova</string> <string name="resume">Nadaljuj</string> <string name="save">Shrani</string> - <string name="search_and_download"><![CDATA[Poišči in prenesi]]></string> + <string name="search_and_download">Poišči in prenesi</string> <string name="search">Išči</string> <string name="searching">Iskanje…</string> <string name="voice_search">Glasovno iskanje</string> @@ -146,9 +148,9 @@ <string name="switch_servers">Zamenjaj strežnik</string> <string name="switch_user">Preklopi</string> <string name="top_unwatched">Najbolje ocenjeno, neogledano</string> - <string name="trailer">Napovednik</string> <plurals name="trailers"> <item quantity="one">Napovedniki</item> + <item quantity="other">Napovedniki</item> </plurals> <string name="tv_dvr_schedule">Program snemalnika</string> <string name="tv_guide">Vodnik</string> @@ -156,6 +158,7 @@ <string name="tv_seasons">Sezone</string> <plurals name="tv_shows"> <item quantity="one">TV oddaje</item> + <item quantity="other">TV oddaje</item> </plurals> <string name="ui_interface">Vmesnik</string> <string name="unknown">Neznano</string> @@ -165,7 +168,9 @@ <string name="video">Video</string> <string name="videos">Videji</string> <string name="watch_live">Glej v živo</string> - <string name="years_old">%1$d let staro</string> + <plurals name="years_old"> + <item quantity="other">%1$d let staro</item> + </plurals> <string name="play_with_transcoding">Igrajte s transkodiranjem</string> <string name="media_information">Informacije o mediji</string> <string name="show_clock">Prikaži uro</string> @@ -180,41 +185,52 @@ <string name="success">Uspeh</string> <string name="official_rating">Starševska ocena</string> <string name="runtime_sort">Trajanje izvajanja</string> - <string name="extras">Dodatki</string> + <string name="extras_title">Dodatki</string> <plurals name="other_extras"> <item quantity="one">Ostalo</item> + <item quantity="other">Ostalo</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">V zakulisju</item> + <item quantity="other">V zakulisju</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Tematske pesmi</item> + <item quantity="other">Tematske pesmi</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Tematski videi</item> + <item quantity="other">Tematski videi</item> </plurals> <plurals name="clips"> <item quantity="one">Posnetki</item> + <item quantity="other">Posnetki</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Izbrisane scene</item> + <item quantity="other">Izbrisane scene</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuji</item> + <item quantity="other">Intervjuji</item> </plurals> <plurals name="scenes"> <item quantity="one">Prizori</item> + <item quantity="other">Prizori</item> </plurals> <plurals name="samples"> <item quantity="one">Vzorci</item> + <item quantity="other">Vzorci</item> </plurals> <plurals name="featurettes"> <item quantity="one">Kratki filmi</item> + <item quantity="other">Kratki filmi</item> </plurals> <plurals name="shorts"> <item quantity="one">Kratki filmi</item> + <item quantity="other">Kratki filmi</item> </plurals> - <string name="favorite_items">Dodaj %s med priljubljene</string> + <string name="favorite_items_title">Dodaj %s med priljubljene</string> <plurals name="downloads"> <item quantity="one">%s prenos</item> <item quantity="two">%s prenosa</item> @@ -260,7 +276,7 @@ <string name="auto_play_next">Samodejno predvajanje naslednjega</string> <string name="check_for_updates">Preveri za posodobitve</string> <string name="combine_continue_next_summary">Velja samo za televizijske serije</string> - <string name="combine_continue_next"><![CDATA[Združi »Nadaljuj z ogledom« in »Naslednje«]]></string> + <string name="combine_continue_next">Združi »Nadaljuj z ogledom« in »Naslednje«</string> <string name="direct_play_ass">Za podnapise v formatu ASS uporabite knjižnico libass</string> <string name="direct_play_pgs">Neposredno predvajanje podnapisov PGS</string> <string name="downmix_stereo">Vedno pretvori v stereo</string> @@ -344,7 +360,7 @@ <string name="aspect_ratio">Razmerje stranic</string> <string name="show_details">Pokaži podrobnosti</string> <string name="image_type">Vrsta slike</string> - <string name="cast_and_crew"><![CDATA[Igralska zasedba in ekipa]]></string> + <string name="cast_and_crew">Igralska zasedba in ekipa</string> <string name="guest_stars">Gostujoči igralci</string> <string name="edge_size">Velikost roba</string> <string name="refresh_rate_switching">Preklapljanje hitrosti osveževanja</string> @@ -541,12 +557,12 @@ <string name="image_type_thumb">Thumb</string> <string name="backdrop_style_dynamic">Slika z dinamičnimi barvami</string> <string name="backdrop_style_image">Samo slika</string> - <string name="enter_url_api_key"><![CDATA[Vnesite URL in API ključ]]></string> + <string name="enter_url_api_key">Vnesite URL in API ključ</string> <string name="api_key">API ključ</string> <string name="seerr_login">Prijava na strežnik Seerr</string> <string name="seerr_jellyfin_user">Jellyfin uporabnik</string> <string name="seerr_local_user">Lokalni uporabnik</string> - <string name="search_and_download_subtitles"><![CDATA[Išči in prenesi podnapise]]></string> + <string name="search_and_download_subtitles">Išči in prenesi podnapise</string> <string name="no_subtitles_found">Ni bilo najdenih podnapisov</string> <string name="series_continueing">danes</string> <string name="in_app_screensaver">Uporabite ohranjevalnik zaslona aplikacije</string> diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml new file mode 100644 index 00000000..2781d45b --- /dev/null +++ b/app/src/main/res/values-sq/strings.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="active_recordings">Rregjistrime aktive</string> + <string name="add_favorite">Shto si të preferuar</string> + <string name="add_server">Shto server</string> + <string name="add_user">Shto përdorues</string> + <string name="collection">Koleksion</string> + <string name="confirm">Konfirmo</string> + <string name="continue_watching">Vazhdo shikimin</string> + <string name="delete">Fshij</string> + <string name="download_and_update"><![CDATA[Shkarko & Përditëso]]></string> + <string name="decimal_seconds">%.2f sekonda</string> + <string name="duration_hours_minutes_seconds">%1$dh%2$dm%3$ds</string> + <string name="duration_hours_minutes">%1$dh%2$dm</string> + <string name="duration_minutes_seconds">%1$dm%2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="directed_by">Drejtuar nga %1$s</string> + <string name="downloading">Duke shkarkuar…</string> + <string name="enabled">Aktivizuar</string> + <string name="ends_at">Përfundon në %1$s</string> + <string name="enter_server_url">Vendos IP-në ose URL-në e serverit</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">Programe TV</string> + <string name="recordings">Rregjistrime</string> + <string name="books">Libra</string> + <string name="user_views">Shikimet e përdoruesit</string> + <string name="appears_on">Shfaqet në</string> + <string name="audio_books">Audiolibra</string> + <string name="server_version_not_supported">Versioni i serverit nuk suportohet</string> + <string name="folders_title">Dosje</string> + <string name="any_language">Çdo gjuhë</string> + <string name="hash_matches">Hash-i përputhet</string> + <string name="override_user_profile_settings_summary">Ndrysho cilësimet e profilit tënd të përdoruesit nga serveri</string> +</resources> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c84a46c3..6664b928 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -31,7 +31,7 @@ <string name="no_results">Inga resultat</string> <string name="name">Namn</string> <string name="loading">Laddar…</string> - <string name="movies">Filmer</string> + <string name="movies_title">Filmer</string> <string name="play">Spela</string> <string name="playlist">Spellista</string> <string name="playlists">Spellistor</string> @@ -73,16 +73,15 @@ <string name="switch_servers">Byt server</string> <string name="switch_user">Byt</string> <string name="top_unwatched">Topprankade osedda</string> - <string name="trailer">Förhandsvisning</string> <plurals name="trailers"> <item quantity="one">Förhandsvisningar</item> - <item quantity="other"/> + <item quantity="other">Förhandsvisningar</item> </plurals> <string name="tv_dvr_schedule">DVR-schema</string> <string name="tv_guide">TV Guide</string> <string name="tv_season">Säsong</string> <string name="tv_seasons">Säsonger</string> - <string name="tv_shows">TV-serier</string> + <string name="tv_shows_title">TV-serier</string> <string name="ui_interface">Gränssnitt</string> <string name="unknown">Okänd</string> <string name="updates">Uppdateringar</string> @@ -91,7 +90,10 @@ <string name="video">Video</string> <string name="videos">Videor</string> <string name="watch_live">Titta live</string> - <string name="years_old">%1$d år gammal</string> + <plurals name="years_old"> + <item quantity="one">%1$d år gammal</item> + <item quantity="other">%1$d år gammal</item> + </plurals> <string name="play_with_transcoding">Spela med transkodning</string> <string name="show_clock">Visa klocka</string> <string name="aired_episode_order">Sändningsordning</string> @@ -105,50 +107,50 @@ <string name="success">Lyckades</string> <string name="official_rating">Åldersgräns</string> <string name="runtime_sort">Speltid</string> - <string name="extras">Extramaterial</string> + <string name="extras_title">Extramaterial</string> <plurals name="other_extras"> <item quantity="one">Annat</item> - <item quantity="other"/> + <item quantity="other">Annat</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bakom kulisserna</item> - <item quantity="other"/> + <item quantity="other">Bakom kulisserna</item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temalåtar</item> - <item quantity="other"/> + <item quantity="other">Temalåtar</item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideor</item> - <item quantity="other"/> + <item quantity="other">Temavideor</item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"/> + <item quantity="other">Klipp</item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Borttagna scener</item> - <item quantity="other"/> + <item quantity="other">Borttagna scener</item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuer</item> - <item quantity="other"/> + <item quantity="other">Intervjuer</item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other"/> + <item quantity="other">Scener</item> </plurals> <plurals name="samples"> <item quantity="one">Exempel</item> - <item quantity="other"/> + <item quantity="other">Exempel</item> </plurals> <plurals name="featurettes"> <item quantity="one">Specialinslag</item> - <item quantity="other"/> + <item quantity="other">Specialinslag</item> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmer</item> - <item quantity="other"/> + <item quantity="other">Kortfilmer</item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedladdning</item> @@ -171,14 +173,14 @@ <string name="confirm">Bekräfta</string> <string name="continue_watching">Fortsätt titta</string> <string name="critic_rating">Kritikerbetyg</string> - <string name="decimal_seconds">%.1f sekunder</string> + <string name="decimal_seconds">%.2f sekunder</string> <string name="default_track">Standard</string> <string name="delete">Radera</string> <string name="directed_by">Regisserad av %1$s</string> <string name="disabled">Inaktiverad</string> <string name="discovered_servers">Upptäckta servrar</string> <string name="enabled">Aktiverad</string> - <string name="enter_server_url">Ange serverns IP eller URL inklusive port</string> + <string name="enter_server_url">Ange serverns IP eller URL</string> <string name="error_loading_collection">Fel vid inläsning av samling %1$s</string> <string name="external_track">Extern</string> <string name="forced_track">Tvingad</string> @@ -205,7 +207,7 @@ <string name="none">Ingen</string> <string name="outro">Slutsekvens</string> <string name="path">Sökväg</string> - <string name="people">Medverkande</string> + <string name="people_title">Medverkande</string> <string name="play_count">Spelningar</string> <string name="play_from_here">Spela härifrån</string> <string name="playback_debug_info">Visa felsökningsinfo för uppspelning</string> @@ -244,7 +246,7 @@ <string name="disable_if_crash">Inaktivera om du upplever krascher</string> <string name="mpv_options">MPV inställningar</string> <string name="exoplayer_options">ExoPlayer inställningar</string> - <string name="download_and_update"><![CDATA[Ladda ner och uppdatera]]></string> + <string name="download_and_update">Ladda ner och uppdatera</string> <string name="skip_segment_intro">Hoppa över intro</string> <string name="skip_segment_outro">Hoppa över eftertexter</string> <string name="skip_segment_unknown">Hoppa över segment</string> @@ -344,7 +346,7 @@ <string name="refresh_rate_switching">Växling av uppdateringsfrekvens</string> <string name="ref_frames">Referensbilder</string> <string name="red">Röd</string> - <string name="resolution_switching">Växling av uppdateringsfrekvens</string> + <string name="resolution_switching">Växling av upplösning</string> <string name="retry">Försök igen</string> <string name="rewatch_next_up">Tillåt omvisning i “Nästa upp”</string> <string name="api_key">API nyckel</string> @@ -362,7 +364,7 @@ <string name="send_app_logs">Skicka apploggar till aktuell server</string> <string name="select_all">Markera alla</string> <string name="seerr_server_added">Seerr-server tillagd</string> - <string name="seerr_login">Logga in på Seerr-server</string> + <string name="seerr_login">Logga in på Seerr</string> <string name="seerr_local_user">Lokal användare</string> <string name="seerr_jellyfin_user">Jellyfin användare</string> <string name="seek_bar_steps">Steg för sökreglage</string> @@ -375,8 +377,8 @@ <string name="skip_ask">Fråga om att hoppa över</string> <string name="skip_automatically">Hoppa över automatiskt</string> <string name="send_app_logs_summary">Användbart för felsökning</string> - <string name="search_and_download_subtitles"><![CDATA[Sök och ladda ner undertexter]]></string> - <string name="search_and_download"><![CDATA[Sök och ladda ner]]></string> + <string name="search_and_download_subtitles">Sök och ladda ner undertexter</string> + <string name="search_and_download">Sök och ladda ner</string> <string name="saturation">Mättnad</string> <string name="save_for_album">Spara till album</string> <string name="save_to_server">Spara till serverns användarprofil</string> @@ -420,11 +422,252 @@ <string name="content_scale_fill">Fyll</string> <string name="customize_home">Anpassa startsidan</string> <string name="customize_home_summary">Välj rader och bilder på startsidan</string> - <string name="delete_item">Är du säker på att du vill ta bort detta objekt?</string> + <string name="delete_item">Är du säker på att du vill radera detta objekt?</string> <string name="discover">Upptäck</string> <string name="display_preset_compact">Wholphin Kompakt</string> <string name="display_preset_default">Wholphin Standard</string> <string name="display_presets">Visningsförinställningar</string> <string name="display_presets_description">Inbyggda förinställningar för att snabbt styla alla rader</string> <string name="display_preset_episode_thumbnails">Avsnittsminiatyrer</string> + <string name="continue_string">Fortsätt</string> + <string name="cyan">Turkos</string> + <string name="magenta">Purpurröd</string> + <string name="stars">Stjärnor</string> + <string name="subtitle_edge_shadow">Skugga</string> + <string name="avc">AVC</string> + <string name="volume_lowest">Lägsta</string> + <string name="volume_low">Låg</string> + <string name="volume_medium">Medel</string> + <string name="volume_high">Hög</string> + <string name="volume_full">Full</string> + <string name="bold_blue">Åskblå</string> + <string name="ffmpeg_fallback">Använd endast FFmpeg om det inte finns någon inbyggd avkodare</string> + <string name="ffmpeg_prefer">Föredra att använda FFmpeg framför inbyggda avkodare</string> + <string name="ffmpeg_never">Använd aldrig FFmpeg-avkodare</string> + <string name="next_up_never">Aldrig</string> + <string name="subtitle_edge_outline">Kontur</string> + <string name="background_style_wrap">Radbryt</string> + <string name="background_style_boxed">Inramad</string> + <string name="prefer_mpv">Föredra MPV</string> + <string name="external_player">Extern spelare</string> + <string name="player_backend_options_subtitles_prefer_mpv">Använd ExoPlayer för HDR-uppspelning</string> + <string name="aspect_ratios_poster">Affisch (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">Kvadratisk (1:1)</string> + <string name="view_options_type_grid">Rutnät</string> + <string name="view_options_type_list">Lista</string> + <string name="view_options_type_list_compact">Kompakt lista</string> + <string name="enter_url_api_key">Ange URL och API-nyckel</string> + <string name="no_subtitles_found">Inga externa undertexter hittades</string> + <string name="in_app_screensaver">Använd skärmsläckare i appen</string> + <string name="lyricist">Textförfattare</string> + <string name="arranger">Arrangör</string> + <string name="mixer">Mixare</string> + <string name="artist">Artist</string> + <string name="album_artist">Albumartist</string> + <string name="album">Album</string> + <string name="popular_songs">Populära låtar</string> + <string name="play_next">Spela nästa</string> + <string name="music_videos">Musikvideor</string> + <string name="lyrics">Låttexter</string> + <string name="hide_lyrics">Döjt låttexter</string> + <string name="show_lyrics">Visa låttexter</string> + <string name="sort">Sortera</string> + <string name="subtitle_delay">Undertextfördröjning</string> + <string name="artists">Artister</string> + <string name="start_after">Starta efter</string> + <string name="apply_all_rows">Tillämpa på alla rader</string> + <string name="continue_watching_click_summary_on">Spela vid klick</string> + <string name="continue_watching_click_summary_off">Visa detaljer vid klick</string> + <string name="tomatoes">Tomater</string> + <string name="split_into_separate_rows">Dela upp i separata rader</string> + <string name="contrast">Kontrast</string> + <string name="sdr">SDR</string> + <string name="go_to_album">Gå till album</string> + <string name="add_to_queue">Lägg till i kön</string> + <string name="albums">Album</string> + <string name="add_row_for">Lägg till rad för %1$s</string> + <string name="now_playing">Spelas nu</string> + <string name="hidden_ui">Dold</string> + <string name="remove_from_queue">Ta bort från kön</string> + <string name="require_login">Kräv inloggning för profil</string> + <string name="remove_next_up">Ta bort serien från Fortsätt titta</string> + <string name="view_removed_next_up">Visa serier som tagits bort från Fortsätt titta</string> + <string name="view_more">Visa mer</string> + <string name="discover_tv">Upptäck TV-serier</string> + <string name="discover_movies">Upptäck filmer</string> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dagar</item> + </plurals> + <string name="layout">Layout</string> + <string name="channels">Kanaler</string> + <string name="results">Resultat</string> + <string name="force_dovi_profile_7_summary">Ignorerar kontroller av enhetskompatibilitet</string> + <string name="pending">Pendlar</string> + <string name="play_trailer">Spela trailer</string> + <string name="visible_ui">Synlig</string> + <string name="combined_search_results">Kombinerade sökresultat</string> + <string name="combined_search_results_on">Visa alla resultat i en enda lista</string> + <string name="combined_search_results_off">Visa resultat efter kategori</string> + <string name="show_voice_search_button">Visa knapp för röstsökning</string> + <string name="ends_at">Slutar %1$s</string> + <string name="no_servers_found">Inga servrar hittades</string> + <string name="only_forced_subtitles">Endast tvingade undertexter</string> + <string name="no_trailers">Inga trailrar</string> + <string name="combine_continue_next">Kombinera Fortsätt titta och kommande avsnitt</string> + <string name="framerate">Bildfrekvens</string> + <string name="bit_depth">Bitdjup</string> + <string name="video_range">Videoomfång</string> + <string name="video_range_type">Typ av videoomfång</string> + <string name="color_space">Färgrymd</string> + <string name="cast_and_crew">Rollista och team</string> + <string name="genres_in">Genrer i %1$s</string> + <string name="remove_continue_watching">Ta bort från Fortsätt titta</string> + <string name="local">Lokal</string> + <string name="play_with">Spela med</string> + <string name="transcoding">Omkodning</string> + <string name="user_interface_language">Språk för användargränssnitt</string> + <string name="display_toggles_title">Visning av betyg</string> + <string name="display_toggles_summary">Anpassa vilka som ska visas</string> + <string name="profile_protection">Profilskydd</string> + <string name="continue_watching_click_behavior">Klickbeteende för Fortsätt titta/kommande avsnitt</string> + <string name="voice_search">Röstsökning</string> + <string name="voice_starting">Startar</string> + <string name="voice_search_prompt">Tala för att söka</string> + <string name="voice_error_network_timeout">Tidsgräns för nätverk överskreds</string> + <string name="voice_error_no_match">Inget tal kändes igen</string> + <string name="voice_error_busy">Röstigenkänning upptagen</string> + <string name="will_remove_pin">Kommer att ta bort PIN-koden</string> + <string name="play_as_type">Spela som?</string> + <string name="favorite_channels_at_beginning">Visa favoritkanaler först</string> + <string name="sort_channels_recently_watched">Sortera kanaler efter senaste visade</string> + <string name="favorite_items_title">Favoritmarkera %s</string> + <string name="play_slideshow">Spela bildspel</string> + <string name="songs">Sånger</string> + <string name="prefer_logos">Föredra att visa logotyper för titlar</string> + <string name="brightness">Ljusstyrka</string> + <string name="cinema_mode">Bioläge</string> + <string name="suggestions_for">Förslag för %1$s</string> + <string name="system_default">Systemstandard</string> + <string name="playback_overrides">Överskrid uppspelning</string> + <string name="processing">Processering</string> + <string name="voice_error_audio">Fel på ljudinspelning</string> + <string name="voice_error_client">Fel på röstigenkänning</string> + <string name="voice_error_permissions">Mikrofonbehörighet krävs</string> + <string name="media_information">Information om Media</string> + <string name="other_extras_title">Annat</string> + <string name="behind_the_scenes_title">Bakom kulisserna</string> + <string name="theme_songs_title">Temasånger</string> + <string name="theme_videos_title">Temavideor</string> + <string name="clips_title">Klipp</string> + <string name="deleted_scenes_title">Borttagna scener</string> + <string name="interviews_title">Intervjuer</string> + <string name="scenes_title">Scener</string> + <string name="featurettes_title">Extramaterial</string> + <string name="shorts_title">Korta klipp</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">FIlmer</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">TV Serie</item> + <item quantity="other">TV Serier</item> + </plurals> + <plurals name="people"> + <item quantity="one">Person</item> + <item quantity="other">Personer</item> + </plurals> + <string name="incorrect">Felaktigt</string> + <string name="title">Titel</string> + <string name="trending">Trendande</string> + <string name="stop_slideshow">Stoppa bildspelet</string> + <string name="slideshow_at_beginning">I början</string> + <string name="no_more_images">Inga fler bilder</string> + <string name="go_to_artist">Gå till artist</string> + <string name="load_from_web_client">Ladda från webbklient</string> + <string name="for_episodes">För episoder</string> + <string name="increase_all_cards_size">Öka storleken för alla kort</string> + <string name="decrease_all_cards_size">Minska storleken för alla kort</string> + <string name="animate">Animera</string> + <string name="max_age_rating">Maximal åldersgräns</string> + <string name="include_types_summary">Vilka typer av objekt ska bilder visas för</string> + <string name="song_has_lyrics">Låten har text</string> + <string name="show_album_cover">Visa omslag till album</string> + <string name="updated_toast">Wholphin har uppdaterats till %s</string> + <string name="last_played">Senast visat</string> + <string name="root_folder">Huvudkatalog</string> + <string name="popularity">Populäritet</string> + <string name="production_status">Produktionsstatus</string> + <string name="experimental_settings">Experimentella inställningar</string> + <string name="confirm_enable_experimental_title">Slå på experimentella inställningar?</string> + <string name="video_tunneling">Videotunnling</string> + <string name="blocklisted">Svartlistad</string> + <string name="unavailable">Otillgänglig</string> + <string name="books">Böcker</string> + <string name="audio_books">Ljudböcker</string> + <string name="duration_hours_minutes_seconds">%1$dt %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dt %2$dm</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dt</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> + <string name="time_left">%1$s kvar</string> + <string name="mpv_hardware_decoding">MPV: Använd hårdvaruavkodning</string> + <string name="interlaced">Sammanflätad</string> + <string name="level">Nivå</string> + <string name="mpv_conf">Redigera mpv.conf</string> + <string name="profile_specific_settings_from_server">Profilinställningar från server</string> + <string name="no_max">Ingen övre gräns</string> + <string name="include_types">Inkludera typer</string> + <string name="load_from_server">Ladda från serverns användarprofil</string> + <string name="pin_digits_hint">[0–9]</string> + <string name="disable_audio_offload">Stäng av hårdvaruavlastning</string> + <string name="disable_audio_offload_summary">Stänga av ljudavlastning kan hjälpa uppspelning som fryser på vissa enheter</string> + <string name="codec">Codec</string> + <string name="anamorphic">Anamorfisk</string> + <string name="nal">NAL</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="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</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">Direct play Dolby Vision Profil 7</string> + <string name="software_decoding_av1">AV1 mjukvaruavkodning</string> + <string name="slideshow_duration">Bildspelslängd</string> + <string name="play_videos_during_slideshow">Spela videor under bildspel</string> + <string name="prefer_ac3_for_surround">Använd AC3 för surroundljud</string> + <string name="brown">Brun</string> + <string name="show_visualizer">Visa visualizer</string> + <string name="show_backdrop">Visa bakgrund</string> + <string name="player_backend">Uppspelning backend</string> + <string name="season_number">Säsong %1$d</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="duration_hours_seconds">%1$dt %2$ds</string> + <string name="blur">Oskärpa</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="show_more">Visa %1$s till</string> + <string name="is_default">Standard</string> + <string name="preferred_audio_language">Föredraget ljudspråk</string> + <string name="override_user_profile_settings">Fler profilinställningar</string> + <string name="use_user_profile">Använd profilinställningar</string> + <string name="override_user_profile_settings_summary">Ignorera serverns profilinställningar</string> + <string name="server_version_not_supported">Serverns version stöds inte</string> + <string name="appears_on">Medverkar i</string> + <string name="photo_albums">Fotoalbum</string> + <string name="no_favorites_found">Inga favoriter hittades!</string> + <string name="confirm_enable_experimental_body">Experimentella inställningar kan vara instabila!\n\n Dessa kan tas bort eller ändras i framtida versioner.</string> + <string name="remove_from_playlist">Ta bort från spellista</string> + <string name="trailers_title">Trailers</string> </resources> diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index e1ebe0ad..c544add8 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -147,7 +147,6 @@ <string name="switch_servers">สลับเซิร์ฟเวอร์</string> <string name="switch_user">สวิตช์</string> <string name="top_unwatched">รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู</string> - <string name="trailer">ตัวอย่าง</string> <plurals name="trailers"> <item quantity="other">ตัวอย่าง</item> </plurals> @@ -166,14 +165,16 @@ <string name="video">วิดีโอ</string> <string name="videos">วิดีโอ</string> <string name="watch_live">ชมสด</string> - <string name="years_old">%1$d ปี</string> + <plurals name="years_old"> + <item quantity="other">%1$d ปี</item> + </plurals> <string name="play_with_transcoding">เล่นโดยใช้การแปลงรหัส</string> <string name="media_information">ข้อมูลสื่อ</string> <string name="show_clock">แสดงนาฬิกา</string> <string name="aired_episode_order">ลำดับตอนที่ออกอากาศ</string> <string name="create_playlist">สร้างเพลย์ลิสต์ใหม่</string> <string name="add_to_playlist">เพิ่มชื่อเพลย์ลิสต์</string> - <string name="one_click_pause">หยุดเกมด้วยการคลิกเพียงครั้งเดียว</string> + <string name="one_click_pause">หยุดเล่นด้วยการคลิกเพียงครั้งเดียว</string> <string name="one_click_pause_summary_on">กดปุ่ม D-Pad ตรงกลางเพื่อหยุด/เล่น</string> <string name="italic_font">ตัวเอียง</string> <string name="font">ฟอนต์</string> @@ -181,7 +182,7 @@ <string name="success">สำเร็จ</string> <string name="official_rating">การให้คะแนนโดยผู้ปกครอง</string> <string name="runtime_sort">รันไทม์</string> - <string name="extras">เพิ่มเติม</string> + <string name="extras_title">เพิ่มเติม</string> <plurals name="other_extras"> <item quantity="other">อื่นๆ</item> </plurals> @@ -215,7 +216,7 @@ <plurals name="shorts"> <item quantity="other">หนังสั้น</item> </plurals> - <string name="favorite_items">ชื่นชอบ %s</string> + <string name="favorite_items_title">ชื่นชอบ %s</string> <string name="ac3_supported">อุปกรณ์รองรับระบบเสียง AC3/Dolby Digital</string> <string name="advanced_settings">การตั้งค่าขั้นสูง</string> <string name="advanced_ui">UI ขั้นสูง</string> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7fba3690..409059eb 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ <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">%1$s seç</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> @@ -63,7 +63,7 @@ <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> + <string name="more">Daha Fazla</string> <plurals name="movies"> <item quantity="one">Film</item> <item quantity="other">Filmler</item> @@ -149,7 +149,6 @@ <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> @@ -170,7 +169,10 @@ <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> + <plurals name="years_old"> + <item quantity="one">%1$d yaşında</item> + <item quantity="other">%1$d yaşında</item> + </plurals> <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> @@ -185,10 +187,10 @@ <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> + <string name="extras_title">Özel Özellikler</string> <plurals name="other_extras"> - <item quantity="one">Diğer Ekstra</item> - <item quantity="other">Diğer Ekstralar</item> + <item quantity="one">Diğer</item> + <item quantity="other">Diğer</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Sahne Arkası</item> @@ -223,8 +225,8 @@ <item quantity="other">Örnekler</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Tanıtım</item> - <item quantity="other">Tanıtımlar</item> + <item quantity="one">Özel Seçenek</item> + <item quantity="other">Özel Seçenekler</item> </plurals> <plurals name="shorts"> <item quantity="one">Kısa Video</item> @@ -471,7 +473,7 @@ <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="favorite_items_title">Favori %s</string> <string name="customize_home_summary">Ana sayfadaki satırları ve görselleri seçin</string> <string name="content_scale_fit">Sığdır</string> <string name="content_scale_crop">Kırp</string> @@ -632,4 +634,85 @@ <string name="view_options_type_list_compact">Sıkışık Liste</string> <string name="sort">Sırala</string> <string name="split_into_separate_rows">Ayrı sıralara ayır</string> + <string name="movies_title">Filmler</string> + <string name="trailers_title">Fragmanlar</string> + <string name="tv_shows_title">Diziler</string> + <string name="other_extras_title">Diğer</string> + <string name="behind_the_scenes_title">Kamera Arkası</string> + <string name="theme_songs_title">Tema Müzikleri</string> + <string name="theme_videos_title">Tema Videoları</string> + <string name="clips_title">Klipler</string> + <string name="deleted_scenes_title">Silinmiş Sahneler</string> + <string name="interviews_title">Röportajlar</string> + <string name="scenes_title">Sahneler</string> + <string name="samples_title">Örnekler</string> + <string name="featurettes_title">Özel Seçenekler</string> + <string name="shorts_title">Kısa Videolar</string> + <string name="people_title">Oyuncular</string> + <string name="genre_movies">%1$s Filmleri</string> + <string name="genre_tv_shows">%1$s Dizileri</string> + <string name="quality_profile">Kalite Profili</string> + <string name="is_default">Varsayılan</string> + <string name="root_folder">Kök Klasör</string> + <string name="popularity">Popülerlik</string> + <string name="production_status">Yapım Durumu</string> + <string name="different_for_hdr">HDR için farklı stil kullan</string> + <string name="experimental_settings">Deneysel Ayarlar</string> + <string name="confirm_enable_experimental_title">Deneysel ayarları açmak istiyor musun?</string> + <string name="confirm_enable_experimental_body">Deneysel ayarlar kararsız çalışabilir! \n\nBu ayarlar sonraki sürümlerde kaldırılabilir veya değiştirilebilir.</string> + <string name="video_tunneling">Video tünelleme</string> + <string name="duration_hours_minutes_seconds">%1$dsa %2$ddk %3$dsn</string> + <string name="duration_hours_minutes">%1$dsa %2$ddk</string> + <string name="duration_minutes_seconds">%1$ddk %2$dsn</string> + <string name="duration_hours">%1$dsa</string> + <string name="duration_minutes">%1$ddk</string> + <string name="duration_seconds">%1$dsn</string> + <string name="time_left">%1$s kaldı</string> + <string name="blocklisted">Engellenenler</string> + <string name="unavailable">Mevcut Değil</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[Stereo olmayan sesleri AC3/Dolby Digital\'e dönüştür]]></string> + <string name="prefer_ac3_for_surround">Çevresel ses için AC3 kullan</string> + <string name="profile_specific_settings_from_server">Sunucudaki kullanıcı profili ayarları</string> + <string name="subtitle_mode">Altyazı Modu</string> + <string name="use_user_profile">Profil ayarlarını kullan</string> + <string name="subtitle_mode_default">Varsayılan</string> + <string name="subtitle_mode_smart">Akıllı</string> + <string name="subtitle_mode_only_forced">Sadece Zorunlu</string> + <string name="subtitle_mode_always">Her zaman</string> + <string name="subtitle_mode_none">Hiçbiri</string> + <string name="preferred_subtitle_language">Tercih edilen altyazı dili</string> + <string name="preferred_audio_language">Tercih edilen ses dili</string> + <string name="override_user_profile_settings">Daha fazla kullanıcı profili ayarı</string> + <string name="override_user_profile_settings_summary">Sunucudaki kullanıcı profili ayarlarını geçersiz kıl</string> + <string name="any_language">Herhangi bir dil</string> + <string name="d_pad_seek_mode_title">Yön Tuşu Arama Çubuğu Modu</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string name="hash_matches">Hash Eşleşmeleri</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV Programları</string> + <string name="recordings">Kayıtlar</string> + <string name="folders_title">Klasörler</string> + <string name="user_views">Kullanıcı Görünümleri</string> + <string name="books">Kitaplar</string> + <string name="audio_books">Sesli Kitaplar</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_minimal_summary">Ekranın alt kısmında küçük ilerleme çubuğu (varsayılan)</string> + <string name="dpad_seek_mode_trickplay_summary">Trickplay önizleme görsellerine sahip arama çubuğu</string> + <string name="dpad_seek_mode_none_summary">İlerleme çubuğu yok</string> + <string name="brown">Kahverengi</string> + <string name="server_version_not_supported">Sunucu sürümü desteklenmiyor</string> + <string name="disable_audio_offload">Ses Donanım Hızlandırmasını Devre Dışı Bırak</string> + <string name="disable_audio_offload_summary">Ses kod çözme işlemlerinin donanıma devredilmesini (offload) devre dışı bırakmak, bazı cihazlardaki oynatma donmalarını düzeltebilir</string> + <string name="dim_after_delay">Belirli bir süre sonra ekranı karart</string> + <string name="dim_percentage">Karartma yüzdesi</string> + <string name="dim_screen">Ekranı karart</string> + <string name="duration_hours_seconds">%1$dsa %2$dsn</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Sezon %1$d</string> + <string name="photo_albums">Fotoğraf Albümleri</string> + <string name="no_favorites_found">Favori bulunamadı!</string> </resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f7835121..6b518809 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -8,7 +8,7 @@ <string name="audio">Аудіо</string> <string name="birthplace">Місце народження</string> <string name="bitrate">Бітрейт</string> - <string name="born">Місце народженя</string> + <string name="born">Дата народження</string> <string name="cancel_recording">Скасувати запис</string> <string name="cancel_series_recording">Скасувати запис серіалу</string> <string name="cancel">Скасувати</string> @@ -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> @@ -31,7 +31,7 @@ <string name="director">Режисер</string> <string name="disabled">Вимкнутий</string> <string name="discovered_servers">Виявлені сервери</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update"><![CDATA[Завантажити і оновити]]></string> <string name="downloading">Завантаження…</string> <string name="enabled">Увімкнено</string> <string name="ends_at">Закінчується о %1$s</string> @@ -42,7 +42,7 @@ <string name="external_track">Зовнішній</string> <string name="favorites">Улюблені</string> <string name="file_size">Розмір</string> - <string name="forced_track">Forced</string> + <string name="forced_track">Примусові</string> <string name="genres">Жанри</string> <string name="go_to_series">Перейти до серії</string> <string name="go_to">Перейти до</string> @@ -65,15 +65,15 @@ <string name="more_like_this">Більше подібного</string> <string name="more">Більше</string> <plurals name="movies"> - <item quantity="one">Фільми</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Фільм</item> + <item quantity="few">Фільми</item> + <item quantity="many">Фільмів</item> + <item quantity="other">Фільмів</item> </plurals> <string name="name">Ім\'я</string> <string name="next_up">Наступний</string> <string name="no_data">Дані відсутні</string> - <string name="no_results">Відсутні результати</string> + <string name="no_results">Результати відсутні</string> <string name="no_servers_found">Сервери не знайдені</string> <string name="no_scheduled_recordings">Заплановані записи відсутні</string> <string name="no_update_available">Немає доступних оновлень</string> @@ -82,10 +82,10 @@ <string name="outro">Завершення</string> <string name="path">Шлях</string> <plurals name="people"> - <item quantity="one">Люди</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Людина</item> + <item quantity="few">Людей</item> + <item quantity="many">Людей</item> + <item quantity="other">Людей</item> </plurals> <string name="play_count">Кількість відтворень</string> <string name="play_from_here">Відтворити звідси</string> @@ -110,7 +110,7 @@ <string name="restart">Перезавантаження</string> <string name="resume">Відновити</string> <string name="save">Зберегти</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download"><![CDATA[Знайти і завантажити]]></string> <string name="search">Пошук</string> <string name="searching">Шукаю…</string> <string name="voice_search">Голосовий пошук</string> @@ -153,22 +153,21 @@ <string name="switch_servers">Перемикнути сервер</string> <string name="switch_user">Перемкнути</string> <string name="top_unwatched">Топ непереглянутих</string> - <string name="trailer">Трейлер</string> <plurals name="trailers"> - <item quantity="one">Трейлери</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Трейлер</item> + <item quantity="few">Трейлери</item> + <item quantity="many">Трейлерів</item> + <item quantity="other">Трейлерів</item> </plurals> <string name="tv_dvr_schedule">Розклад DVR</string> <string name="tv_guide">Посібник</string> <string name="tv_season">Сезон</string> <string name="tv_seasons">Сезони</string> <plurals name="tv_shows"> - <item quantity="one">Серіали</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">ТВ-шоу</item> + <item quantity="few">ТВ-шоу</item> + <item quantity="many">ТВ-шоу</item> + <item quantity="other">ТВ-шоу</item> </plurals> <string name="ui_interface">Інтерфейс</string> <string name="unknown">Невідомо</string> @@ -178,7 +177,6 @@ <string name="video">Відео</string> <string name="videos">Відоси</string> <string name="watch_live">Дивитись онлайн</string> - <string name="years_old">%1$d років</string> <string name="play_with_transcoding">Відтворити з перекодуванням</string> <string name="media_information">Інформація</string> <string name="show_clock">Показати годиник</string> @@ -193,72 +191,72 @@ <string name="success">Успіх</string> <string name="official_rating">Вікова оцінка</string> <string name="runtime_sort">Час виконання</string> - <string name="extras">Додатково</string> + <string name="extras_title">Додатково</string> <plurals name="other_extras"> <item quantity="one">Інше</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Іншого</item> + <item quantity="many">Іншого</item> + <item quantity="other">Іншого</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Позакадром</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Позакадром</item> + <item quantity="many">Позакадром</item> + <item quantity="other">Позакадром</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Тематичні пісні</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Музична тема</item> + <item quantity="few">Музичні теми</item> + <item quantity="many">Музичних тем</item> + <item quantity="other">Музичних тем</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Тематичні відео</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Тематичне відео</item> + <item quantity="few">Тематичні відео</item> + <item quantity="many">Тематичних відео</item> + <item quantity="other">Тематичних відео</item> </plurals> <plurals name="clips"> - <item quantity="one">Кліпи</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Кліп</item> + <item quantity="few">Кліпи</item> + <item quantity="many">Кліпів</item> + <item quantity="other">Кліпів</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Видалені сцени</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Видалена сцена</item> + <item quantity="few">Видалені сцени</item> + <item quantity="many">Видалених сцен</item> + <item quantity="other">Видалених сцен</item> </plurals> <plurals name="interviews"> <item quantity="one">Інтерв\'ю</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Інтерв\'ю</item> + <item quantity="many">Інтерв\'ю</item> + <item quantity="other">Інтерв\'ю</item> </plurals> <plurals name="scenes"> - <item quantity="one">Сцени</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Сцена</item> + <item quantity="few">Сцени</item> + <item quantity="many">Сцен</item> + <item quantity="other">Сцен</item> </plurals> <plurals name="samples"> - <item quantity="one">Зразки</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Зразок</item> + <item quantity="few">Зразка</item> + <item quantity="many">Зразків</item> + <item quantity="other">Зразків</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Короткометражні фільми</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Короткометражний фільм</item> + <item quantity="few">Короткометражні фільми</item> + <item quantity="many">Короткометражних фільмім</item> + <item quantity="other">Короткометражних фільмім</item> </plurals> <plurals name="shorts"> - <item quantity="one">Шорти</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Коротке відео</item> + <item quantity="few">Короткі відео</item> + <item quantity="many">Коротких відео</item> + <item quantity="other">Коротких відео</item> </plurals> <plurals name="downloads"> <item quantity="one">%s завантаження</item> @@ -293,7 +291,7 @@ <string name="auto_play_next">Автоматичне відтворення наступного</string> <string name="check_for_updates">Перевірити оноалення</string> <string name="combine_continue_next_summary">Застосовується тільки до серіалів</string> - <string name="direct_play_ass">Пряме відтворення субтитрів ASS</string> + <string name="direct_play_ass">Використовувати libass для субтитрів ASS</string> <string name="direct_play_pgs">Пряме відтворення субтитрів PGS</string> <string name="downmix_stereo">Завжди змішувати в стерео</string> <string name="ffmpeg_extension_pref">Використовувати модуль декодера FFmpeg</string> @@ -338,7 +336,7 @@ <string name="background_color">Колір фону</string> <string name="reset">Скинути</string> <string name="bold_font">Жирний шрифт</string> - <string name="player_backend">Playback Backend</string> + <string name="player_backend">Компонент відтворення</string> <string name="mpv_hardware_decoding">MPV: Використовувати апаратне декодування</string> <string name="disable_if_crash">Вимкніть, якщо ви стикаєтеся з проблемами</string> <string name="mpv_options">Варіанти MPV</string> @@ -376,9 +374,9 @@ <string name="spacing">Відступ(Інтервал)</string> <string name="aspect_ratio">Співвідношення сторін</string> <string name="show_details">Показати деталі</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Об’єднати функції «Продовжити перегляд» та «Наступне»]]></string> <string name="image_type">Тип зображення</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew"><![CDATA[Актори та знімальна група]]></string> <string name="guest_stars">Запрошені зірки</string> <string name="edge_size">Розмір контуру</string> <string name="refresh_rate_switching">Перемикання частоти оновлення</string> @@ -475,12 +473,12 @@ <string name="zoom_out">Зменшити</string> <string name="slideshow_duration">Тривалість слайд-шоу</string> <string name="play_videos_during_slideshow">Відтворення відео під час слайд-шоу</string> - <string name="favorite_items">Улюблені %s</string> + <string name="favorite_items_title">Улюблені %s</string> <plurals name="days"> - <item quantity="one">день</item> - <item quantity="few">дні</item> - <item quantity="many">днів</item> - <item quantity="other">днів</item> + <item quantity="one">%s день</item> + <item quantity="few">%s дні</item> + <item quantity="many">%s днів</item> + <item quantity="other">%s днів</item> </plurals> <string name="quick_connect">Швидке підключення</string> <string name="quick_connect_summary">Дозвольте іншому пристрою увійти у ваш обліковий запис</string> @@ -513,4 +511,180 @@ <string name="display_presets">Налаштування дисплея</string> <string name="display_presets_description">Вбудовані налаштування для швидкого оформлення всіх рядків</string> <string name="customize_home_summary">Обери рядки і зображення для головної сторінки</string> + <string name="people_title">Люди</string> + <string name="trailers_title">Трейлери</string> + <string name="tv_shows_title">Телешоу</string> + <string name="other_extras_title">Інше</string> + <string name="behind_the_scenes_title">За лаштунками</string> + <string name="movies_title">Фільми</string> + <string name="theme_songs_title">Музичні теми</string> + <string name="theme_videos_title">Тематичні відео</string> + <string name="clips_title">Кліпи</string> + <string name="deleted_scenes_title">Видалені сцени</string> + <string name="interviews_title">Інтерв\'ю</string> + <string name="scenes_title">Сцени</string> + <string name="samples_title">Зразки</string> + <string name="featurettes_title">Короткометражні фільми</string> + <string name="shorts_title">Короткі відео</string> + <plurals name="minutes"> + <item quantity="one">%s хвилина</item> + <item quantity="few">%s хвилини</item> + <item quantity="many">%s хвилин</item> + <item quantity="other">%s хвилин</item> + </plurals> + <string name="require_login">Запитувати пароль для профілю</string> + <string name="pin_digits_hint">[0-9]</string> + <string name="combined_search_results">Спільні результати пошуку</string> + <string name="combined_search_results_on">Показувати всі результати одним списком</string> + <string name="combined_search_results_off">Показувати результати за категоріями</string> + <string name="show_voice_search_button">Показувати кнопку голосового пошуку</string> + <string name="visible_ui">Видимий</string> + <string name="hidden_ui">Прихований</string> + <string name="results">Результати</string> + <string name="albums">Альбоми</string> + <string name="artists">Виконавці</string> + <string name="songs">Пісні</string> + <string name="go_to_artist">До виконавця</string> + <string name="now_playing">Зараз грає</string> + <string name="instant_mix">Швидкий мікс</string> + <string name="go_to_album">До альбому</string> + <string name="add_to_queue">Додати до черги</string> + <string name="start_after">Почати після</string> + <string name="animate">Анімація</string> + <string name="max_age_rating">Обмеження за віком</string> + <string name="no_max">Без обмежень</string> + <string name="for_all_ages">Для будь-якого віку</string> + <string name="up_to_age">До %s років</string> + <string name="screensaver">Заставка</string> + <string name="screensaver_settings">Налаштування заставки</string> + <string name="start_screensaver">Запустити заставку</string> + <string name="duration">Тривалість</string> + <string name="photos">Фото</string> + <string name="include_types">Включити типи</string> + <string name="include_types_summary">Для яких типів елементів показувати зображення</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="display_preset_default">Wholphin за замовчуванням</string> + <string name="display_preset_series_thumb">Мініатюри серіалів</string> + <string name="display_preset_episode_thumbnails">Мініатюри серій</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="next_up_never">Ніколи</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> + <string name="external_player">Зовнішній плеєр</string> + <string name="player_backend_options_subtitles_prefer_mpv">Використовувати ExoPlayer для відтворення HDR</string> + <string name="aspect_ratios_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">Квадрат (1:1)</string> + <string name="image_type_primary">Основне</string> + <string name="image_type_thumb">Мініатюра</string> + <string name="view_options_type_grid">Сітка</string> + <string name="view_options_type_list">Список</string> + <string name="view_options_type_list_compact">Компактний список</string> + <string name="backdrop_style_dynamic">Зображення з динамічним кольором</string> + <string name="backdrop_style_image">Лише зображення</string> + <string name="enter_url_api_key"><![CDATA[Введіть URL та API-ключ]]></string> + <string name="api_key">API-ключ</string> + <string name="seerr_login">Увійти до сервера Seerr</string> + <string name="seerr_jellyfin_user">Користувач Jellyfin</string> + <string name="seerr_local_user">Локальний користувач</string> + <string name="search_and_download_subtitles"><![CDATA[Пошук та завантаження субтитрів]]></string> + <string name="no_subtitles_found">Віддалених субтитрів не знайдено</string> + <string name="series_continueing">Дотепер</string> + <string name="in_app_screensaver">Використовувати вбудовану заставку</string> + <string name="delete_item">Ви впевнені, що хочете видалити цей елемент?</string> + <string name="show_media_management">Показувати параметри керування медіа</string> + <string name="search_for">Шукати %s</string> + <string name="actor">Актор</string> + <string name="composer">Композитор</string> + <string name="writer">Сценарист</string> + <string name="guest_star">Запрошена зірка</string> + <string name="producer">Продюсер</string> + <string name="conductor">Диригент</string> + <string name="lyricist">Автор тексту</string> + <string name="arranger">Аранжувальник</string> + <string name="engineer">Звукорежисер</string> + <string name="mixer">Звукорежисер зведення</string> + <string name="creator">Автор</string> + <string name="artist">Виконавець</string> + <string name="album_artist">Виконавець альбому</string> + <string name="album">Альбом</string> + <string name="popular_songs">Популярні пісні</string> + <string name="play_next">Відтворити наступним</string> + <string name="music_videos">Музичні відео</string> + <string name="lyrics">Текст пісн</string> + <string name="hide_lyrics">Приховати текст пісні</string> + <string name="show_lyrics">Показати текст пісні</string> + <string name="song_has_lyrics">Пісня має текст</string> + <string name="remove_from_queue">Видалити з черги</string> + <string name="show_album_cover">Показувати обкладинку альбому</string> + <string name="show_visualizer">Показувати візуалізатор</string> + <string name="show_backdrop">Показувати фон</string> + <string name="play_as_type">Відтворити як?</string> + <string name="visualizer_rationale">Для візуалізації аудіо, що зараз відтворюється, потрібен дозвіл на запис звуку.</string> + <string name="continue_string">Продовжити</string> + <string name="select_all">Вибрати все</string> + <string name="separate_types">Розділяти типи</string> + <string name="prefer_logos">Надавати перевагу логотипам для назв</string> + <string name="updated_toast">Wholphin оновлено до %s</string> + <string name="remove_continue_watching">Видалити з «Продовжити перегляд»</string> + <string name="remove_next_up">Видалити серіал з «Далі»</string> + <string name="view_removed_next_up">Переглянути серіали, видалені з «Далі»</string> + <string name="view_more">Переглянути більше</string> + <string name="discover_tv">Знайти ТВ-шоу</string> + <string name="discover_movies">Знайти фільми</string> + <string name="studios_in">Студії у %1$s</string> + <string name="last_played">Останні відтворені</string> + <string name="system_default">Системне за замовчуванням</string> + <string name="ass_subtitle_mode_libass">Пряме відтворення через libass</string> + <string name="ass_subtitle_mode_exoplayer">Пряме відтворення через вбудований ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Вшивати/транскодувати на сервері</string> + <string name="ass_subtitle_playback">Відтворення субтитрів SSA/ASS</string> + <string name="cinema_mode">Режим кінотеатру</string> + <string name="show_more">Показати ще %1$s</string> + <string name="skip_behavior">Поведінка пропуску</string> + <string name="skip_behavior_summary">Для інтро, титрів тощо</string> + <string name="play_with">Відтворити за допомогою</string> + <string name="transcoding">Транскодування</string> + <string name="user_interface_language">Мова інтерфейсу</string> + <string name="display_toggles_title">Відображення рейтингів</string> + <string name="display_toggles_summary">Налаштуйте, що відображати</string> + <string name="profile_protection">Захист профілю</string> + <string name="continue_watching_click_behavior">Дія при натисканні в «Продовжити перегляд»</string> + <string name="continue_watching_click_summary_on">Відтворювати при натисканні</string> + <string name="continue_watching_click_summary_off">Показувати деталі при натисканні</string> + <string name="stars">Зірки</string> + <string name="tomatoes">Томати</string> + <string name="sort">Сортування</string> + <string name="split_into_separate_rows">Розділити на окремі рядки</string> + <string name="display_preset_compact">Wholphin компактний</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 84d69985..a8f40586 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -62,7 +62,7 @@ <string name="max_bitrate">最大比特率</string> <string name="more_like_this">类似推荐</string> <string name="more">更多</string> - <string name="movies">电影</string> + <string name="movies_title">电影</string> <string name="name">名称</string> <string name="next_up">即将播放</string> <string name="no_data">无数据</string> @@ -72,7 +72,7 @@ <string name="none">无</string> <string name="outro">片尾</string> <string name="path">路径</string> - <string name="people">演职人员</string> + <string name="people_title">演职人员</string> <string name="play_count">播放次数</string> <string name="play_from_here">从此处播放</string> <string name="play">播放</string> @@ -121,7 +121,6 @@ <string name="switch_servers">切换服务器</string> <string name="switch_user">切换</string> <string name="top_unwatched">高分未看</string> - <string name="trailer">预告片</string> <plurals name="trailers"> <item quantity="other">预告片</item> </plurals> @@ -129,7 +128,7 @@ <string name="tv_guide">指南</string> <string name="tv_season">季</string> <string name="tv_seasons">季</string> - <string name="tv_shows">电视节目</string> + <string name="tv_shows_title">电视节目</string> <string name="ui_interface">界面</string> <string name="unknown">未知</string> <string name="updates">更新</string> @@ -138,7 +137,9 @@ <string name="video">视频</string> <string name="videos">视频</string> <string name="watch_live">观看直播</string> - <string name="years_old">%1$d 岁</string> + <plurals name="years_old"> + <item quantity="other">%1$d 岁</item> + </plurals> <string name="play_with_transcoding">转码播放</string> <string name="show_clock">显示时钟</string> <string name="aired_episode_order">剧集播出顺序</string> @@ -152,7 +153,7 @@ <string name="success">成功</string> <string name="official_rating">内容分级</string> <string name="runtime_sort">播放时长</string> - <string name="extras">附加内容</string> + <string name="extras_title">附加内容</string> <plurals name="other_extras"> <item quantity="other">其他</item> </plurals> @@ -254,9 +255,9 @@ <string name="reset">重置</string> <string name="bold_font">粗体</string> <string name="player_backend">播放后端</string> - <string name="mpv_hardware_decoding">mpv:使用硬件解码</string> + <string name="mpv_hardware_decoding">MPV:使用硬件解码</string> <string name="disable_if_crash">如果遇到崩溃,请禁用</string> - <string name="mpv_options">mpv 选项</string> + <string name="mpv_options">MPV 选项</string> <string name="exoplayer_options">ExoPlayer 选项</string> <string name="skip_segment_unknown">跳过片段</string> <string name="skip_segment_commercial">跳过广告</string> @@ -271,7 +272,7 @@ <string name="remove">移除</string> <string name="dolby_vision">杜比视界</string> <string name="dolby_atmos">杜比全景声</string> - <string name="mpv_use_gpu_next">mpv:使用 gpu-next</string> + <string name="mpv_use_gpu_next">MPV:使用 gpu-next</string> <string name="mpv_conf">编辑 mpv.conf</string> <string name="discard_change">舍弃更改?</string> <string name="subtitle_margin">边距</string> @@ -390,7 +391,7 @@ <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="upgrade_mpv_toast">除 HDR 视频外,MPV 现在是默认播放器。\n您可以在设置中更改此设置。</string> <string name="hdr_subtitle_style">HDR 字幕样式</string> <string name="image_subtitle_opacity">图像字幕不透明度</string> <string name="brightness">亮度</string> @@ -446,7 +447,7 @@ <string name="display_presets">显示预设</string> <string name="display_presets_description">可快速设置所有行样式的内置预设</string> <string name="customize_home_summary">选择首页上的行和图片</string> - <string name="favorite_items">收藏的 %s</string> + <string name="favorite_items_title">收藏的 %s</string> <string name="content_scale_fit">适应</string> <string name="content_scale_crop">裁剪</string> <string name="content_scale_fill">填充</string> @@ -476,8 +477,8 @@ <string name="magenta">紫红色</string> <string name="subtitle_edge_outline">轮廓</string> <string name="subtitle_edge_shadow">阴影</string> - <string name="prefer_mpv">优先使用 mpv</string> - <string name="player_backend_options_subtitles_prefer_mpv">HDR 播放使用 ExoPlayer</string> + <string name="prefer_mpv">优先使用 MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">对于 HDR 播放使用 ExoPlayer</string> <string name="aspect_ratios_poster">海报 (2:3)</string> <string name="aspect_ratios_16_9">16:9</string> <string name="aspect_ratios_4_3">4:3</string> @@ -535,15 +536,15 @@ <string name="search_for">搜索 %s</string> <string name="select_all">全选</string> <string name="albums">专辑</string> - <string name="artists">音乐人</string> + <string name="artists">艺人</string> <string name="songs">歌曲</string> - <string name="go_to_artist">前往音乐人</string> + <string name="go_to_artist">前往艺人</string> <string name="now_playing">正在播放</string> <string name="instant_mix">即时混音</string> <string name="go_to_album">前往专辑</string> <string name="add_to_queue">添加到待播列表</string> <string name="remove_from_queue">从待播列表中移除</string> - <string name="album_artist">专辑音乐人</string> + <string name="album_artist">专辑艺人</string> <string name="album">专辑</string> <string name="popular_songs">热门歌曲</string> <string name="play_next">接下来播放</string> @@ -582,7 +583,7 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">转码</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">综合搜索结果</string> <string name="combined_search_results_on">在单个列表中显示所有结果</string> <string name="combined_search_results_off">按类别显示结果</string> @@ -605,4 +606,85 @@ <string name="user_interface_language">用户界面语言</string> <string name="sort">排序</string> <string name="split_into_separate_rows">拆分为单独的行</string> + <string name="trailers_title">预告片</string> + <string name="other_extras_title">其他</string> + <string name="behind_the_scenes_title">幕后花絮</string> + <string name="theme_songs_title">主题曲</string> + <string name="theme_videos_title">主题视频</string> + <string name="clips_title">片段</string> + <string name="deleted_scenes_title">删减片段</string> + <string name="interviews_title">访谈</string> + <string name="scenes_title">场景</string> + <string name="samples_title">样片</string> + <string name="featurettes_title">特辑</string> + <string name="shorts_title">短片</string> + <plurals name="movies"> + <item quantity="other">电影</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">电视节目</item> + </plurals> + <plurals name="people"> + <item quantity="other">演职人员</item> + </plurals> + <string name="genre_movies">%1$s 电影</string> + <string name="genre_tv_shows">%1$s 电视节目</string> + <string name="quality_profile">质量配置</string> + <string name="is_default">默认</string> + <string name="root_folder">根文件夹</string> + <string name="popularity">热度</string> + <string name="production_status">制作状态</string> + <string name="different_for_hdr">为 HDR 使用不同的样式</string> + <string name="experimental_settings">实验性设置</string> + <string name="confirm_enable_experimental_title">启用实验性设置?</string> + <string name="confirm_enable_experimental_body">实验性设置可能不稳定!\n\n 这些设置可能会在任何版本中被移除或更改。</string> + <string name="video_tunneling">隧道播放模式</string> + <string name="duration_hours_minutes_seconds">%1$d小时%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小时%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> + <string name="duration_hours">%1$d小时</string> + <string name="duration_minutes">%1$d分</string> + <string name="duration_seconds">%1$d秒</string> + <string name="time_left">还剩%1$s</string> + <string name="blocklisted">被列入黑名单</string> + <string name="unavailable">不可用</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[将非立体声音频转码为 AC3/杜比数字]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 处理环绕声音频</string> + <string name="profile_specific_settings_from_server">来自服务器的用户配置设置</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用您的配置设置</string> + <string name="subtitle_mode_default">默认</string> + <string name="subtitle_mode_smart">智能</string> + <string name="subtitle_mode_only_forced">仅强制</string> + <string name="subtitle_mode_always">始终</string> + <string name="subtitle_mode_none">无</string> + <string name="preferred_subtitle_language">首选字幕语言</string> + <string name="preferred_audio_language">首选音频语言</string> + <string name="override_user_profile_settings">更多用户配置设置</string> + <string name="override_user_profile_settings_summary">从服务器覆盖您的用户配置设置</string> + <string name="any_language">任何语言</string> + <string name="d_pad_seek_mode_title">方向键进度条模式</string> + <string name="dpad_seek_mode_trickplay">特技播放</string> + <string name="hash_matches">哈希匹配</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">电视节目</string> + <string name="recordings">录制内容</string> + <string name="folders_title">文件夹</string> + <string name="user_views">用户视图</string> + <string name="books">书籍</string> + <string name="audio_books">有声读物</string> + <string name="brown">棕色</string> + <string name="dpad_seek_mode_minimal">极简</string> + <string name="dpad_seek_mode_minimal_summary">屏幕底部的小型进度条(默认)</string> + <string name="dpad_seek_mode_trickplay_summary">带有特技播放预览图的进度条</string> + <string name="dpad_seek_mode_none_summary">无进度条</string> + <string name="server_version_not_supported">服务器版本不支持</string> + <string name="appears_on">出现在</string> + <string name="disable_audio_offload">禁用音频硬件分流</string> + <string name="disable_audio_offload_summary">禁用分流音频解码可能会解决某些设备上的播放卡顿问题</string> + <string name="dim_after_delay">延迟后调暗屏幕</string> + <string name="dim_percentage">调暗百分比</string> + <string name="dim_screen">调暗屏幕</string> + <string name="photo_albums">相册</string> + <string name="no_favorites_found">未找到收藏!</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 e79705c3..ca8bd810 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -9,7 +9,7 @@ <string name="loading">載入中…</string> <string name="more_like_this">類似作品</string> <string name="more">更多</string> - <string name="movies">電影</string> + <string name="movies_title">電影</string> <string name="name">名稱</string> <string name="next_up">接下來播放</string> <string name="no_data">無資料</string> @@ -31,7 +31,7 @@ <string name="recently_released">最近發行</string> <string name="recommended">推薦</string> <string name="record_program">錄製節目</string> - <string name="restart">重頭播放</string> + <string name="restart">從頭播放</string> <string name="save">儲存</string> <string name="search">搜尋</string> <string name="searching">搜尋中…</string> @@ -52,7 +52,6 @@ <string name="suggestions">推薦</string> <string name="switch_servers">切換伺服器</string> <string name="switch_user">切換</string> - <string name="trailer">預告片</string> <plurals name="trailers"> <item quantity="other">預告片</item> </plurals> @@ -189,7 +188,7 @@ <string name="mark_watched">標記為已觀看</string> <string name="max_bitrate">最大位元率</string> <string name="outro">片尾</string> - <string name="people">演職人員</string> + <string name="people_title">演職人員</string> <string name="play_from_here">從此處播放</string> <string name="playback_debug_info">顯示播放偵錯資訊</string> <string name="playback">播放</string> @@ -202,9 +201,11 @@ <string name="studios">製作公司</string> <string name="top_unwatched">高評分未觀看</string> <string name="tv_dvr_schedule">錄影排程</string> - <string name="tv_shows">電視劇</string> + <string name="tv_shows_title">電視劇</string> <string name="ui_interface">介面</string> - <string name="years_old">%1$d 歲</string> + <plurals name="years_old"> + <item quantity="other">%1$d 歲</item> + </plurals> <string name="play_with_transcoding">轉碼播放</string> <string name="one_click_pause_summary_on">按下方向鍵中央以暫停/播放</string> <plurals name="deleted_scenes"> @@ -243,7 +244,7 @@ <plurals name="scenes"> <item quantity="other">精選片段</item> </plurals> - <string name="extras">特別收錄</string> + <string name="extras_title">特別收錄</string> <plurals name="samples"> <item quantity="other">試看片段</item> </plurals> @@ -451,10 +452,10 @@ <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_lowest">最小音量</string> + <string name="volume_low">小音量</string> <string name="volume_medium">中等音量</string> - <string name="volume_high">高音量</string> + <string name="volume_high">大音量</string> <string name="volume_full">最大音量</string> <string name="purple">紫色</string> <string name="orange">橘色</string> @@ -480,7 +481,7 @@ <string name="background_style_boxed">長方底框</string> <string name="prefer_mpv">優先使用 MPV</string> <string name="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</string> - <string name="favorite_items">我的最愛的 %s</string> + <string name="favorite_items_title">我的最愛的 %s</string> <string name="display_preset_default">Wholphin 預設樣式</string> <string name="display_preset_compact">Wholphin 緊湊樣式</string> <string name="display_preset_series_thumb">劇集縮圖樣式</string> @@ -582,7 +583,7 @@ <string name="cinema_mode">影院模式</string> <string name="play_with">播放方式</string> <string name="transcoding">轉碼</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="combined_search_results">合併搜尋結果</string> <string name="combined_search_results_on">在單一列表中顯示所有結果</string> <string name="combined_search_results_off">按類別顯示結果</string> @@ -603,4 +604,97 @@ <string name="view_options_type_list">列表</string> <string name="view_options_type_list_compact">緊湊列表</string> <string name="user_interface_language">介面語言</string> + <string name="sort">排序</string> + <string name="split_into_separate_rows">分開為不同列</string> + <string name="trailers_title">預告片</string> + <string name="other_extras_title">其他</string> + <string name="behind_the_scenes_title">幕後花絮</string> + <string name="theme_songs_title">主題曲</string> + <string name="theme_videos_title">主題影片</string> + <string name="clips_title">片段</string> + <string name="deleted_scenes_title">刪剪片段</string> + <string name="interviews_title">訪談</string> + <string name="scenes_title">精選片段</string> + <string name="samples_title">試看片段</string> + <string name="featurettes_title">幕後特輯</string> + <string name="shorts_title">短片</string> + <plurals name="movies"> + <item quantity="other">電影</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="other">電視劇</item> + </plurals> + <plurals name="people"> + <item quantity="other">演職人員</item> + </plurals> + <string name="genre_movies">%1$s 電影</string> + <string name="genre_tv_shows">%1$s 電視劇</string> + <string name="quality_profile">品質設定</string> + <string name="is_default">預設</string> + <string name="root_folder">根目錄</string> + <string name="popularity">熱門程度</string> + <string name="production_status">製作狀態</string> + <string name="different_for_hdr">HDR 使用不同的樣式</string> + <string name="video_tunneling">影片直通模式</string> + <string name="experimental_settings">實驗性設定</string> + <string name="confirm_enable_experimental_title">啟用實驗性設定?</string> + <string name="confirm_enable_experimental_body">實驗性設定可能不穩定!\n在後續版本中可能會被移除或變更。</string> + <string name="duration_hours_minutes_seconds">%1$d小時%2$d分%3$d秒</string> + <string name="duration_hours_minutes">%1$d小時%2$d分</string> + <string name="duration_minutes_seconds">%1$d分%2$d秒</string> + <string name="duration_hours">%1$d小時</string> + <string name="duration_minutes">%1$d分</string> + <string name="duration_seconds">%1$d秒</string> + <string name="time_left">還剩 %1$s</string> + <string name="blocklisted">被列入黑名單</string> + <string name="unavailable">無法請求</string> + <string name="prefer_ac3_for_surround_summary"><![CDATA[將非立體聲音訊轉碼為 AC3/杜比數位格式]]></string> + <string name="prefer_ac3_for_surround">使用 AC3 處理環繞音效</string> + <string name="profile_specific_settings_from_server">由伺服器提供的個人化設定</string> + <string name="subtitle_mode">字幕模式</string> + <string name="use_user_profile">使用伺服器設定</string> + <string name="subtitle_mode_default">預設</string> + <string name="subtitle_mode_smart">智慧模式</string> + <string name="subtitle_mode_only_forced">僅強制字幕</string> + <string name="subtitle_mode_always">總是顯示</string> + <string name="subtitle_mode_none">不顯示</string> + <string name="preferred_subtitle_language">偏好字幕語言</string> + <string name="preferred_audio_language">偏好音軌語言</string> + <string name="override_user_profile_settings">更多個人化設定</string> + <string name="override_user_profile_settings_summary">覆寫伺服器提供的個人化設定</string> + <string name="any_language">任何語言</string> + <string name="brown">棕色</string> + <string name="d_pad_seek_mode_title">方向鍵進度條模式</string> + <string name="dpad_seek_mode_minimal">精簡</string> + <string name="dpad_seek_mode_trickplay">縮圖預覽</string> + <string name="dpad_seek_mode_minimal_summary">在畫面底部顯示小進度條 (預設)</string> + <string name="dpad_seek_mode_trickplay_summary">顯示有縮圖預覽的進度條</string> + <string name="dpad_seek_mode_none_summary">不顯示進度條</string> + <string name="hash_matches">雜湊值相符</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">電視劇</string> + <string name="recordings">錄影節目</string> + <string name="folders_title">資料夾</string> + <string name="books">書籍</string> + <string name="audio_books">有聲書</string> + <string name="server_version_not_supported">不支援此伺服器版本</string> + <string name="user_views">使用者檢視方式</string> + <string name="appears_on">出現於</string> + <string name="disable_audio_offload">停用音訊硬體卸載</string> + <string name="disable_audio_offload_summary">停用音訊解碼卸載或許可以解決某些裝置的播放卡住問題</string> + <string name="dim_after_delay">閒置後調暗螢幕</string> + <string name="dim_percentage">調暗程度</string> + <string name="dim_screen">螢幕調暗</string> + <string name="no_favorites_found">未添加任何最愛項目!</string> + <string name="photo_albums">相簿</string> + <string name="duration_hours_seconds">%1$d小時%2$d分</string> + <string name="season_episode_number">第%1$d季 第%2$d集</string> + <string name="season_episode_number_range">第%1$d季 第%2$d-%3$d集</string> + <string name="season_episode_number_padded">第%1$02d季 第%2$02d集</string> + <string name="season_episode_number_padded_range">第%1$02d季 第%2$02d-%3$02d集</string> + <string name="episode_number_short">第 %1$d 集</string> + <string name="season_number">第 %1$d 季</string> + <string name="remove_from_playlist">從播放清單中移除</string> + <string name="login_not_authorized">您目前無存取此伺服器的權限</string> + <string name="login_credentials_expired">登入憑證已過期,請重新登入</string> </resources> diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 770a6b72..45a6f7b2 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -1,5 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <!-- Names should be prefixed with 'fa_' and use the icon name from FontAwesome --> + <!-- Uses v6 of FontAwesome: https://fontawesome.com/v6/icons/classic/solid/<name> --> + <!-- Value is '&#x' then the unicode value, then semicolon --> <string name="fa_tag" translatable="false"></string> <string name="fa_user" translatable="false"></string> <string name="fa_location_dot" translatable="false"></string> @@ -56,4 +59,7 @@ <string name="fa_compass" translatable="false"></string> <string name="fa_repeat" translatable="false"></string> <string name="fa_compact_disc" translatable="false"></string> + <string name="fa_xmark" translatable="false"></string> + <string name="fa_dice" translatable="false"></string> + <string name="fa_circle_xmark" translatable="false"></string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd511f29..cff98add 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,13 @@ <string name="continue_watching">Continue watching</string> <string name="critic_rating">Critic Rating</string> <string name="decimal_seconds">%.2f seconds</string> + <string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string> + <string name="duration_hours_minutes">%1$dh %2$dm</string> + <string name="duration_hours_seconds">%1$dh %2$ds</string> + <string name="duration_minutes_seconds">%1$dm %2$ds</string> + <string name="duration_hours">%1$dh</string> + <string name="duration_minutes">%1$dm</string> + <string name="duration_seconds">%1$ds</string> <string name="default_track">Default</string> <string name="delete">Delete</string> <string name="died">Died</string> @@ -66,7 +73,7 @@ <string name="max_bitrate">Max bitrate</string> <string name="more_like_this">More like this</string> <string name="more">More</string> - <string name="movies">Movies</string> + <string name="movies_title">Movies</string> <string name="name">Name</string> <string name="next_up">Next Up</string> <string name="no_data">No data</string> @@ -78,7 +85,7 @@ <string name="only_forced_subtitles">Only Forced Subtitles</string> <string name="outro">Outro</string> <string name="path">Path</string> - <string name="people">People</string> + <string name="people_title">People</string> <string name="play_count">Play Count</string> <string name="play_from_here">Play from here</string> <string name="play">Play</string> @@ -89,6 +96,7 @@ <string name="playlists">Playlists</string> <string name="preview">Preview</string> <string name="profile_specific_settings">User Profile Settings</string> + <string name="profile_specific_settings_from_server">User profile settings from server</string> <string name="queue">Queue</string> <string name="recap">Recap</string> <string name="recently_added_in">Recently added in %1$s</string> @@ -144,14 +152,20 @@ <string name="suggestions">Suggestions</string> <string name="switch_servers">Switch servers</string> <string name="switch_user">Switch</string> + <string name="time_left">%1$s left</string> <string name="top_unwatched">Top Rated Unwatched</string> - <string name="trailer">Trailer</string> - <string name="trailers">Trailers</string> + <string name="trailers_title">Trailers</string> <string name="tv_dvr_schedule">DVR Schedule</string> <string name="tv_guide">Guide</string> <string name="tv_season">Season</string> <string name="tv_seasons">Seasons</string> - <string name="tv_shows">TV Shows</string> + <string name="season_episode_number">S%1$d E%2$d</string> + <string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string> + <string name="season_episode_number_padded">S%1$02dE%2$02d</string> + <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> + <string name="episode_number_short">E%1$d</string> + <string name="season_number">Season %1$d</string> + <string name="tv_shows_title">TV Shows</string> <string name="ui_interface">Interface</string> <string name="unknown">Unknown</string> <string name="updates">Updates</string> @@ -159,8 +173,12 @@ <string name="video_scale">Video Scale</string> <string name="video">Video</string> <string name="videos">Videos</string> - <string name="watch_live">Watch live</string> - <string name="years_old">%1$d years old</string> + <string name="watch_live">Watch live</string>g + <plurals name="years_old"> + <item quantity="zero">%1$d years old</item> + <item quantity="one">%1$d year old</item> + <item quantity="other">%1$d years old</item> + </plurals> <string name="play_with_transcoding">Play with transcoding</string> <string name="media_information">Media Information</string> <string name="show_clock">Show Clock</string> @@ -176,19 +194,19 @@ <string name="official_rating">Parental Rating</string> <string name="runtime_sort">Runtime</string> - <string name="extras">Extras</string> - <string name="other_extras">Other</string> - <string name="behind_the_scenes">Behind the Scenes</string> - <string name="theme_songs">Theme Songs</string> - <string name="theme_videos">Theme Videos</string> - <string name="clips">Clips</string> - <string name="deleted_scenes">Deleted Scenes</string> - <string name="interviews">Interviews</string> - <string name="scenes">Scenes</string> - <string name="samples">Samples</string> - <string name="featurettes">Featurettes</string> - <string name="shorts">Shorts</string> - <string name="favorite_items">Favorite %s</string> + <string name="extras_title">Extras</string> + <string name="other_extras_title">Other</string> + <string name="behind_the_scenes_title">Behind the Scenes</string> + <string name="theme_songs_title">Theme Songs</string> + <string name="theme_videos_title">Theme Videos</string> + <string name="clips_title">Clips</string> + <string name="deleted_scenes_title">Deleted Scenes</string> + <string name="interviews_title">Interviews</string> + <string name="scenes_title">Scenes</string> + <string name="samples_title">Samples</string> + <string name="featurettes_title">Featurettes</string> + <string name="shorts_title">Shorts</string> + <string name="favorite_items_title">Favorite %s</string> <plurals name="trailers"> <item quantity="zero">Trailers</item> @@ -391,8 +409,10 @@ <string name="incorrect">Incorrect</string> <string name="pin_too_short">PIN must be 4 keys or longer</string> <string name="will_remove_pin">Will remove PIN</string> - <string name="pin_digits_hint">[0-9]</string> + <string name="pin_digits_hint">[0–9]</string> <string name="image_cache_size">Image disk cache size (MB)</string> + <string name="disable_audio_offload">Disable audio hardware offload</string> + <string name="disable_audio_offload_summary">Disabling offloading audio decoding may fix playback freezes on some devices</string> <string name="view_options">View options</string> <string name="columns">Columns</string> <string name="spacing">Spacing</string> @@ -492,7 +512,7 @@ <string name="image_subtitle_opacity">Image subtitle opacity</string> <string name="brightness">Brightness</string> - <string name="contrast">Constrast</string> + <string name="contrast">Contrast</string> <string name="saturation">Saturation</string> <string name="hue">Hue</string> <string name="red">Red</string> @@ -545,6 +565,8 @@ <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="prefer_ac3_for_surround_summary"><![CDATA[Transcode non-stereo audio to AC3/Dolby Digital]]></string> + <string name="prefer_ac3_for_surround">Use AC3 for surround sound audio</string> <string name="start_after">Start after</string> <string name="animate">Animate</string> <string name="max_age_rating">Max age rating</string> @@ -586,6 +608,7 @@ <string name="orange">Orange</string> <string name="bold_blue">Bold Blue</string> <string name="black">Black</string> + <string name="brown">Brown</string> <string-array name="app_theme_colors"> <item>@string/purple</item> <item>@string/blue</item> @@ -593,6 +616,8 @@ <item>@string/orange</item> <item>@string/bold_blue</item> <item>@string/black</item> + <item>@string/red</item> + <item>@string/brown</item> </string-array> <string name="skip_ignore">Ignore</string> @@ -732,8 +757,8 @@ </string-array> <string-array name="screensaver_item_types"> - <item>@string/movies</item> - <item>@string/tv_shows</item> + <item>@string/movies_title</item> + <item>@string/tv_shows_title</item> <item>@string/photos</item> </string-array> @@ -822,4 +847,82 @@ <string name="tomatoes">Tomatoes</string> <string name="sort">Sort</string> <string name="split_into_separate_rows">Split into separate rows</string> + <string name="genre_movies">%1$s Movies</string> + <string name="genre_tv_shows">%1$s TV Shows</string> + <string name="quality_profile">Quality profile</string> + <string name="is_default">Default</string> + <string name="root_folder">Root folder</string> + <string name="popularity">Popularity</string> + <string name="production_status">Production status</string> + <string name="different_for_hdr">Use different style for HDR</string> + <string name="experimental_settings">Experimental settings</string> + <string name="confirm_enable_experimental_title">Enable experimental settings?</string> + <string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string> + <string name="video_tunneling">Video tunneling</string> + + <string name="d_pad_seek_mode_title">D-Pad seek bar mode</string> + <string name="dpad_seek_mode_minimal">Minimal</string> + <string name="dpad_seek_mode_trickplay">Trickplay</string> + <string-array name="dpad_seek_mode_options"> + <item>@string/dpad_seek_mode_minimal</item> + <item>@string/dpad_seek_mode_trickplay</item> + <item>@string/none</item> + </string-array> + <string name="dpad_seek_mode_minimal_summary">Small seek bar along the bottom of the screen (default)</string> + <string name="dpad_seek_mode_trickplay_summary">Seek bar with trickplay preview images</string> + <string name="dpad_seek_mode_none_summary">No seek bar</string> + <string-array name="dpad_seek_mode_summaries"> + <item>@string/dpad_seek_mode_minimal_summary</item> + <item>@string/dpad_seek_mode_trickplay_summary</item> + <item>@string/dpad_seek_mode_none_summary</item> + </string-array> + + <string name="blocklisted">Blocklisted</string> + <string name="unavailable">Unavailable</string> + <string name="subtitle_mode">Subtitle mode</string> + + <string name="use_user_profile">Use your profile setting</string> + + <string name="subtitle_mode_default">Default</string> + <string name="subtitle_mode_smart">Smart</string> + <string name="subtitle_mode_only_forced">Only Forced</string> + <string name="subtitle_mode_always">Always</string> + <string name="subtitle_mode_none">None</string> + <string-array name="subtitle_mode_options"> + <item>@string/use_user_profile</item> + <item>@string/subtitle_mode_default</item> + <item>@string/subtitle_mode_smart</item> + <item>@string/subtitle_mode_only_forced</item> + <item>@string/subtitle_mode_always</item> + <item>@string/subtitle_mode_none</item> + </string-array> + + <string-array name="home_click_to_play_options"> + <item>@string/continue_watching_click_summary_off</item> + <item>@string/continue_watching_click_summary_on</item> + </string-array> + + <string name="preferred_subtitle_language">Preferred subtitle language</string> + <string name="preferred_audio_language">Preferred audio language</string> + <string name="override_user_profile_settings">More user profile settings</string> + <string name="override_user_profile_settings_summary">Override your user profile settings from the server</string> + <string name="any_language">Any language</string> + <string name="hash_matches">Hash matches</string> + <string name="subtitles_hearing_impaired">SDH</string> + <string name="programs">TV programs</string> + <string name="recordings">Recordings</string> + <string name="folders_title">Folders</string> + <string name="user_views">User views</string> + <string name="books">Books</string> + <string name="audio_books">Audio books</string> + <string name="server_version_not_supported">Server version not supported</string> + <string name="appears_on">Appears on</string> + <string name="dim_after_delay">Dim screen after delay</string> + <string name="dim_percentage">Dim percentage</string> + <string name="dim_screen">Dim screen</string> + <string name="photo_albums">Photo albums</string> + <string name="no_favorites_found">No favorites found!</string> + <string name="remove_from_playlist">Remove from playlist</string> + <string name="login_credentials_expired">Credentials expired, please login in again</string> + <string name="login_not_authorized">You are not authorized to access the server at this time</string> </resources> diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 4c21b699..354f3298 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -603,15 +603,14 @@ components: example: false required: - name - - hostname - - port - - apiKey - - useSsl + # - hostname + # - port + # - useSsl - activeProfileId - - activeProfileName + # - activeProfileName - activeDirectory - is4k - - minimumAvailability + # - minimumAvailability - isDefault SonarrSettings: type: object @@ -682,15 +681,15 @@ components: example: false required: - name - - hostname - - port - - apiKey - - useSsl + # - hostname + # - port + # - apiKey + # - useSsl - activeProfileId - - activeProfileName + # - activeProfileName - activeDirectory - is4k - - enableSeasonFolders + # - enableSeasonFolders - isDefault ServarrTag: type: object @@ -1091,8 +1090,16 @@ components: status: type: integer example: 0 - description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + description: Availability of the season readOnly: true + status4k: + type: integer + example: 0 + description: Availability of the season in 4K + readOnly: true + TvShowStatus: + type: string + enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] TvDetails: type: object properties: @@ -1198,8 +1205,6 @@ components: type: array items: $ref: '#/components/schemas/Season' - status: - type: string tagline: type: string type: @@ -1299,6 +1304,12 @@ components: type: integer example: 0 description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + seasons: + type: array + readOnly: true + nullable: true + items: + $ref: '#/components/schemas/Season' requests: type: array readOnly: true @@ -1396,6 +1407,17 @@ components: name: type: string example: 720p/1080p + RootFolder: + type: object + properties: + id: + type: integer + example: 1 + freeSpace: + type: long + path: + type: string + example: /movies PageInfo: type: object properties: @@ -6154,9 +6176,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -6166,6 +6188,10 @@ paths: userid: type: integer nullable: true + tags: + type: array + items: + type: integer required: - mediaType - mediaId @@ -6261,9 +6287,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -7061,7 +7087,13 @@ paths: server: $ref: '#/components/schemas/RadarrSettings' profiles: - $ref: '#/components/schemas/ServiceProfile' + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + rootFolders: + type: array + items: + $ref: '#/components/schemas/RootFolder' /service/sonarr: get: summary: Get non-sensitive Sonarr server list @@ -7101,7 +7133,13 @@ paths: server: $ref: '#/components/schemas/SonarrSettings' profiles: - $ref: '#/components/schemas/ServiceProfile' + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + rootFolders: + type: array + items: + $ref: '#/components/schemas/RootFolder' /service/sonarr/lookup/{tmdbId}: get: summary: Get series from Sonarr diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt new file mode 100644 index 00000000..0b18cc8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.UUID + +class AlphabetSortNameTest { + @Test + fun prefersSortNameOverName() { + val dto = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MUSIC_ARTIST, + name = "The Beatles", + sortName = "beatles", + ) + + assertEquals("beatles", dto.alphabetSortName) + assertEquals("beatles", BaseItem(dto).sortName) + } + + @Test + fun fallsBackToNameWhenSortNameMissing() { + val dto = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.STUDIO, + name = "The Walt Disney Company", + sortName = null, + ) + + assertEquals("The Walt Disney Company", dto.alphabetSortName) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt new file mode 100644 index 00000000..3998201f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.res.Resources +import com.github.damontecres.wholphin.data.ExtrasItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +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.ExtraType +import org.jellyfin.sdk.model.api.ImageType +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ExtrasServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk<Context>() + private val mockApiClient = mockk<ApiClient>() + private val mockImageUrlService = mockk<ImageUrlService>() + + private val mockUserLibraryApi = mockk<UserLibraryApi>() + private val mockResources = mockk<Resources>() + + private val extrasService = ExtrasService(mockApiClient, mockContext, mockImageUrlService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApiClient.userLibraryApi } returns mockUserLibraryApi + every { mockContext.resources } returns mockResources + every { mockResources.getString(any()) } returns "Interviews" + every { mockResources.getQuantityString(any(), any()) } returns "Interviews" + every { mockResources.getQuantityString(any(), any(), any<Int>()) } returns "Interviews" + + every { mockImageUrlService.getItemImageUrl(any<BaseItem>(), ImageType.PRIMARY, any(), any(), any()) } returns + "https://localhost/image.jpg" + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + @Test + fun `Test grouping extras`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(1, extras.size) + val extra = extras.first() + Assert.assertTrue(extra is ExtrasItem.Group) + extra as ExtrasItem.Group + val extrasIds = extra.items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extra.type, ExtraType.INTERVIEW) + } + + @Test + fun `Test grouping extras with multiple types`() = + runTest { + val extrasDtos = + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Group) + } + val extrasIds = extras.flatMap { (it as ExtrasItem.Group).items.map { it.id } }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test extras not grouped`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ), + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + extras.forEachIndexed { index, item -> + Assert.assertTrue("index=$index", item is ExtrasItem.Single) + } + val extrasIds = extras.map { (it as ExtrasItem.Single).item.id }.toSet() + Assert.assertEquals(extrasDtos.map { it.id }.toSet(), extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } + + @Test + fun `Test mixed grouping`() = + runTest { + val extrasDtos = + listOf( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.CLIP, + ), + ) + + List(3) { + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.VIDEO, + extraType = ExtraType.INTERVIEW, + ) + } + + coEvery { mockUserLibraryApi.getSpecialFeatures(any(), any()) } returns + successResponse(extrasDtos) + + val extras = extrasService.getExtras(UUID.randomUUID()) + Assert.assertEquals(2, extras.size) + Assert.assertTrue(extras[0] is ExtrasItem.Single) + Assert.assertTrue(extras[1] is ExtrasItem.Group) + + val extrasIds = listOf((extras[0] as ExtrasItem.Single).item.id) + (extras[1] as ExtrasItem.Group).items.map { it.id } + Assert.assertEquals(extrasDtos.map { it.id }, extrasIds) + Assert.assertEquals(extras[0].type, ExtraType.CLIP) + Assert.assertEquals(extras[1].type, ExtraType.INTERVIEW) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt new file mode 100644 index 00000000..ee32c7dd --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -0,0 +1,483 @@ +package com.github.damontecres.wholphin.services + +import android.app.SearchManager +import android.content.Intent +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.test.assertIs +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.toServerString +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class IntentServiceTest { + private val api: ApiClient = mockk() + private val serverRepository: ServerRepository = mockk() + private val serverDao: JellyfinServerDao = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + private val userLibraryApi: UserLibraryApi = mockk() + + lateinit var intentService: IntentService + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + private val itemId = UUID.randomUUID() + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setup() { + intentService = IntentService(api, serverRepository, userPreferencesService) + every { api.userLibraryApi } returns userLibraryApi + coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId)) + every { serverRepository.serverDao } returns serverDao + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { userPreferencesService.flow } returns + flow { + emit( + UserPreferences( + AppPreferences.getDefaultInstance().update(block), + null, + ), + ) + } + } + + private fun setupAutoSignInUnprotected() { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest { + setupAutoSignInUnprotected() + + val intent = Intent() + val result = intentService.prepare(intent) + assertNull(result) + + coVerify { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign disabled in with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertNull(result) + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified does not exist`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null + coEvery { serverRepository.restoreSession(serverId, userId) } returns null + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test parseIntent with view item`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.MediaItem>(first) + + assertEquals(itemId, first.itemId) + + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view and no itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(0, destinations.size) + assertTrue(result.addHomeToBackStack) + + coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view and switch user`() = + runTest { + setupAutoSignInUnprotected() + + val newUserId = UUID.randomUUID() + val newServerId = UUID.randomUUID() + val newCurrentUser = currentUser(newServerId, newUserId) + + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { + serverRepository.restoreSession( + newServerId, + newUserId, + ) + } returns newCurrentUser + every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("userId", newUserId.toString()) + putExtra("serverId", newServerId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(0, destinations.size) + assertTrue(result.addHomeToBackStack) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getUser(newServerId, newUserId) } + coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with view, itemId, and switch user`() = + runTest { + setupAutoSignInUnprotected() + + val newUserId = UUID.randomUUID() + val newServerId = UUID.randomUUID() + val newCurrentUser = currentUser(newServerId, newUserId) + + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) + coEvery { + serverRepository.restoreSession( + newServerId, + newUserId, + ) + } returns newCurrentUser + every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user + + val intent = + Intent().apply { + action = Intent.ACTION_VIEW + putExtra("userId", newUserId.toString()) + putExtra("serverId", newServerId.toString()) + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.MediaItem>(first) + + assertEquals(itemId, first.itemId) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getUser(newServerId, newUserId) } + coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with search`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = Intent.ACTION_SEARCH + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.Search>(first) + + assertEquals("", first.query) + } + + @Test + fun `Test parseIntent with search query`() = + runTest { + setupAutoSignInUnprotected() + + val query = "query123" + val intent = + Intent().apply { + action = Intent.ACTION_SEARCH + putExtra(SearchManager.QUERY, query) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs<Destination.Search>(first) + + assertEquals(query, first.query) + } + + @Test + fun `Test parseIntent with play but no itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Error>(result) + } + + @Test + fun `Test parseIntent with play itemId`() = + runTest { + setupAutoSignInUnprotected() + + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + putExtra("itemId", itemId.toString()) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs<Destination.MediaItem>(first) + assertIs<Destination.Playback>(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + + coVerify { userLibraryApi.getItem(itemId) } + } + + @Test + fun `Test parseIntent with play itemId and params`() = + runTest { + setupAutoSignInUnprotected() + + val position = 4_000L + val shuffle = true + val intent = + Intent().apply { + action = IntentService.ACTION_PLAYBACK + putExtra("itemId", itemId.toString()) + putExtra("position", position) + putExtra("shuffle", shuffle) + } + val result = intentService.parseIntent(intent) + assertIs<IntentResult.Target>(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs<Destination.MediaItem>(first) + assertIs<Destination.Playback>(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + assertEquals(shuffle, second.shuffle) + assertEquals(position, second.positionMs) + + coVerify { userLibraryApi.getItem(itemId) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt index d4f4d436..256ee9d8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt @@ -8,17 +8,17 @@ import androidx.lifecycle.LifecycleRegistry import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow 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.Before import org.junit.Rule @@ -36,14 +36,14 @@ class LatestNextUpSchedulerServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() = WholphinDispatchers.reset() private fun createService() = LatestNextUpSchedulerService( @@ -56,7 +56,7 @@ class LatestNextUpSchedulerServiceTest { @Test fun cancels_latestNextUp_work_when_user_null() = - runTest { + runTest(testDispatcher) { createService() currentUser.value = null diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt index 4c605df4..d4590cf7 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt @@ -5,15 +5,15 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import kotlinx.serialization.UseSerializers import kotlinx.serialization.json.Json import org.jellyfin.sdk.api.client.ApiClient @@ -56,14 +56,14 @@ class LatestNextUpServiceTests { @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockApi.tvShowsApi } returns mockTvShowsApi } @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() } @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt new file mode 100644 index 00000000..e88c2b81 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +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.preferences.InterfacePreferences +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.operations.LibraryApi +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.UserDto +import org.jellyfin.sdk.model.api.UserPolicy +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class MediaManagementServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk<Context>() + private val mockApiClient = mockk<ApiClient>() + private val mockServerRepository = mockk<ServerRepository>() + private val mockUserPreferencesService = mockk<UserPreferencesService>() + + private val mockLibraryApi = mockk<LibraryApi>() + + private val mediaManagementService = + MediaManagementService(mockContext, mockApiClient, mockServerRepository, mockUserPreferencesService) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { mockApiClient.libraryApi } returns mockLibraryApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private val movie = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = true, + ), + ) + private val recording = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.RECORDING, + canDelete = true, + ), + ) + + private val movieCannotDelete = + BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MOVIE, + canDelete = false, + ), + ) + + private val userWithLiveTv = + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + policy = + mockk<UserPolicy> { + every { enableLiveTvManagement } returns true + }, + ) + + private val userWithoutLiveTv = + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + policy = + mockk<UserPolicy> { + every { enableLiveTvManagement } returns false + }, + ) + + private val prefsEnabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = true + }.build() + }.build() + + private val prefsDisabled = + AppPreferences + .newBuilder() + .apply { + interfacePreferences = + InterfacePreferences + .newBuilder() + .apply { + enableMediaManagement = false + }.build() + }.build() + + @Test + fun `Test canDelete`() { + Assert.assertTrue(mediaManagementService.canDelete(movie, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(movie, prefsDisabled)) + Assert.assertFalse(mediaManagementService.canDelete(movieCannotDelete, prefsEnabled)) + } + + @Test + fun `Test canDelete recording`() { + every { mockServerRepository.currentUserDto } returns userWithLiveTv + Assert.assertTrue(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @Test + fun `Test canDelete recording no permission`() { + every { mockServerRepository.currentUserDto } returns userWithoutLiveTv + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsEnabled)) + Assert.assertFalse(mediaManagementService.canDelete(recording, prefsDisabled)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test successful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } returns successResponse(Unit) + + val deletedItems = mutableListOf<DeletedItem>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Success) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(1, deletedItems.size) + Assert.assertEquals(deletedItems[0].item.id, movie.id) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Test unsuccessful delete`() = + runTest { + coEvery { mockLibraryApi.deleteItem(any()) } throws InvalidStatusException(403) + + val deletedItems = mutableListOf<DeletedItem>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + mediaManagementService.deletedItemFlow.toList(deletedItems) + } + val result = mediaManagementService.deleteItem(movie) + Assert.assertTrue(result is DeleteResult.Error) + coVerify(exactly = 1) { mockLibraryApi.deleteItem(movie.id) } + Assert.assertEquals(0, deletedItems.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt new file mode 100644 index 00000000..06402b88 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt @@ -0,0 +1,485 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.test.nonBlankString +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.AuthenticationResult +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.jellyfin.sdk.model.api.UserDto +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ServerRepositoryTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + val temporaryFolder: TemporaryFolder = + TemporaryFolder + .builder() + .assureDeletion() + .build() + + private val mockContext = mockk<Context>(relaxed = true) + private val mockJellyfin = mockk<Jellyfin>() + private val mockJellyfinServerDao = mockk<JellyfinServerDao>() + private val mockApiClient = mockk<ApiClient>() + private val dataStore = + DataStoreFactory.create( + serializer = AppPreferencesSerializer(), + produceFile = { temporaryFolder.newFile("test_datastore.pb") }, + scope = CoroutineScope(testDispatcher), + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + + private val mockUserApi = mockk<UserApi>() + private val mockSystemApi = mockk<SystemApi>() + private val mockSharedPreferences = mockk<SharedPreferences>(relaxed = true) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(testDispatcher) + + every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences + every { mockApiClient.userApi } returns mockUserApi + every { mockApiClient.systemApi } returns mockSystemApi + coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto) + coEvery { mockSystemApi.getPublicSystemInfo() } returns + Response( + PublicSystemInfo( + id = serverId.toServerString(), + serverName = "test server", + version = "10.11.11", + ), + 200, + emptyMap(), + ) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1") + every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device") + every { mockApiClient.update(any(), any(), any(), any()) } just Runs + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + private fun create() = + ServerRepository( + mockContext, + mockJellyfin, + mockJellyfinServerDao, + mockApiClient, + dataStore, + testDispatcher, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + + private fun setUpCurrentUser( + serverRepository: ServerRepository, + currentUser: JellyfinUser = user, + ) { + every { mockApiClient.baseUrl } returns server.url + every { mockApiClient.accessToken } returns user.accessToken + Assert.assertNull(serverRepository.currentUser) + (serverRepository.current as MutableStateFlow).update { + CurrentUser(server, currentUser) + } + Assert.assertNotNull(serverRepository.currentUser) + } + + @Test + fun `Test overriding state`() { + val serverRepository = create() + Assert.assertNull(serverRepository.currentServer) + Assert.assertNull(serverRepository.currentUser) + setUpCurrentUser(serverRepository, currentUser = user) + Assert.assertEquals(serverId, serverRepository.currentServer?.id) + Assert.assertEquals(userId, serverRepository.currentUser?.id) + } + + @Test + fun `Test changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.changeUser(server, user) + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } + } + + @Test + fun `Test restoreUser via changeUser`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + serverRepository.restoreSession(server.id, user.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + } + + @Test + fun `Test restoreUser via shortcut`() = + runTest { + every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) + + val serverRepository = create() + setUpCurrentUser(serverRepository) + + serverRepository.restoreSession(server.id, user.id) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) } + verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) } + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + + // TODO fix this, need to check userDto too +// Assert.assertEquals(userDto, serverRepository.currentUserDto) + } + + @Test + fun `Test remove user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val serverRepository = create() + Assert.assertNull(serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different user than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + + val otherUser = + JellyfinUser( + rowId = 2, + id = UUID.randomUUID(), + name = "other", + serverId = serverId, + accessToken = "other token", + ) + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = otherUser) + Assert.assertEquals(otherUser, serverRepository.currentUser) + + serverRepository.removeUser(user) + verify(exactly = 0) { mockApiClient.update(accessToken = null) } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + Assert.assertEquals(otherUser, serverRepository.currentUser) + } + + @Test + fun `Test remove current user`() = + runTest { + coEvery { mockJellyfinServerDao.deleteUser(any(), any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeUser(user) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = nonBlankString(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + } + + @Test + fun `Test remove different server than current`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + val toRemove = JellyfinServer(UUID.randomUUID(), null, "http://jellyfin.local", null) + serverRepository.removeServer(toRemove) + + Assert.assertEquals(server, serverRepository.currentServer) + verify(exactly = 0) { + mockApiClient.update( + accessToken = any(), + baseUrl = any(), + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(toRemove.id) } + } + + @Test + fun `Test remove current server`() = + runTest { + coEvery { mockJellyfinServerDao.deleteServer(any()) } just Runs + val serverRepository = create() + setUpCurrentUser(serverRepository, currentUser = user) + + serverRepository.removeServer(server) + + Assert.assertNull(serverRepository.current.value) + verify { + mockApiClient.update( + accessToken = null, + baseUrl = null, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } + } + + @Test + fun `Test changeUser via auth`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, null) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot<JellyfinUser>() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Newly created user, so rowId should be 0 + Assert.assertEquals(0, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + } + + @Test + fun `Test changeUser via auth for existing`() = + runTest { + every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs + every { mockJellyfinServerDao.addOrUpdateUser(any()) } returns user + + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = user.accessToken, + ) + val serverRepository = create() + serverRepository.changeUser(server.url, authResult, user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateServer(server) } + val savedUser = slot<JellyfinUser>() + verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(capture(savedUser)) } + // Existing user, so rowId should not change + Assert.assertEquals(user.rowId, savedUser.captured.rowId) + Assert.assertEquals(user.id, savedUser.captured.id) + // PIN should be removed when re-authing existing user + Assert.assertNull(savedUser.captured.pin) + + Assert.assertEquals(server, serverRepository.currentServer) + Assert.assertEquals(user, serverRepository.currentUser) + Assert.assertEquals(userDto, serverRepository.currentUserDto) + + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } + } + + @Test + fun `Test changeUser via invalid auth`() { + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = serverId.toServerString(), + accessToken = null, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = null, + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = userDto, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + Assert.assertThrows(IllegalArgumentException::class.java) { + runTest { + val authResult = + AuthenticationResult( + user = null, + serverId = "invalid-uuid", + accessToken = user.accessToken, + ) + create().changeUser(server.url, authResult, user) + } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt similarity index 84% rename from app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt rename to app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index dbf04447..37671f8d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,14 +1,16 @@ -package com.github.damontecres.wholphin.test +package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.preferences.UserProfileSettings import io.mockk.every import io.mockk.mockk import org.jellyfin.sdk.model.UUID @@ -22,7 +24,7 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) -class TestStreamChoiceServiceBasic( +class StreamChoiceServiceTest( val input: TestInput, ) { @Test @@ -103,6 +105,56 @@ class TestStreamChoiceServiceBasic( ), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false), + ), + ), ) } } @@ -278,6 +330,7 @@ class TestStreamChoiceServiceSmart( TestInput( 0, SubtitlePlaybackMode.SMART, + userSubtitleLang = "eng", subtitles = listOf( subtitle(0, "eng", false), @@ -489,7 +542,7 @@ class TestStreamChoiceServiceMultipleChoices( fun data(): Collection<TestInput> = listOf( TestInput( - 0, + 1, SubtitlePlaybackMode.ALWAYS, subtitles = listOf( @@ -750,6 +803,80 @@ class TestStreamChoiceServiceOnlyForcedClientOverride( } } +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceAppPreferences( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection<TestInput> = + listOf( + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = UserProfileSettings.USE_USER_PROFILE, + ), + TestInput( + expectedIndex = 0, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "eng", + ), + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "spa", + ), + TestInput( + expectedIndex = 1, // ItemPlayback + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + appSubtitleLang = "spa", + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, @@ -759,6 +886,9 @@ data class TestInput( val subtitles: List<MediaStream>, val itemPlayback: ItemPlayback? = null, val plc: PlaybackLanguageChoice? = null, + val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, ) { override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" } @@ -800,7 +930,15 @@ private fun runTest(input: TestInput) { candidates = input.subtitles, itemPlayback = input.itemPlayback, playbackLanguageChoice = input.plc, - prefs = UserPreferences(AppPreferences.getDefaultInstance()), + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences( + preferredAudioLanguage = input.appAudioLang, + preferredSubtitleLanguage = input.appSubtitleLang, + subtitleMode = input.appSubtitleMode, + ), + ), ) Assert.assertEquals(input.expectedIndex, result?.index) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index 436fd360..403a2715 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -8,19 +8,19 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset 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.MutableStateFlow 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 @@ -48,12 +48,12 @@ class SuggestionServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) } @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() io.mockk.unmockkObject(GetItemsRequestHandler) } 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 index 0d52db15..f66e7292 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -11,19 +11,19 @@ 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.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset 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.flow.MutableStateFlow 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 @@ -43,14 +43,14 @@ class SuggestionsSchedulerServiceTest { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + WholphinDispatchers.configure(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() = WholphinDispatchers.reset() private fun createService() = SuggestionsSchedulerService( diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt new file mode 100644 index 00000000..4ef8ab8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemDestinationTest { + @Test + fun audioWithAlbumId_navigatesToAlbumWithInitialSongId() { + val songId = UUID.randomUUID() + val albumId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = albumId), false) + + val dest = item.destination() + + assertEquals(Destination.MediaItem::class, dest::class) + val media = dest as Destination.MediaItem + assertEquals(albumId, media.itemId) + assertEquals(BaseItemKind.MUSIC_ALBUM, media.type) + assertEquals(songId, media.initialSongId) + } + + @Test + fun audioWithoutAlbumId_returnsSelfAsMediaItem() { + val songId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = null), false) + + val dest = item.destination() + + assertEquals(songId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.AUDIO, dest.type) + assertNull(dest.initialSongId) + } + + @Test + fun movie_usesStandardDestination() { + val movieId = UUID.randomUUID() + val item = BaseItem(movie(id = movieId), false) + + val dest = item.destination() + + assertEquals(movieId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.MOVIE, dest.type) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt new file mode 100644 index 00000000..6a801c16 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.findIndexByNumberOrId +import com.github.damontecres.wholphin.util.BlockingList +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert +import org.junit.Test + +class TestFindIndexByNumberOrId { + val season0Id = UUID.randomUUID() + val season2Id = UUID.randomUUID() + + fun create( + indexNumber: Int, + parentIndexNumber: Int, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + indexNumber = indexNumber, + parentIndexNumber = parentIndexNumber, + parentId = + when (parentIndexNumber) { + 0 -> season0Id + 2 -> season2Id + else -> null + }, + ), + ) + + val epS02E01 = create(1, 2) + val epS02E02 = create(2, 2) + val epS02E03 = create(3, 2) + val epS02E04 = create(4, 2) + val epS02E05 = create(5, 2) + val epS02E06 = create(6, 2) + val epS00E08 = create(8, 0) + val epS00E02 = create(2, 0) + + private val episodes = + listOf( + epS02E01, + epS02E02, + epS02E03, + ) + + private val missingEpisodes = + listOf( + epS02E01, + epS02E04, + epS02E05, + ) + + private val withSpecials = + listOf( + epS02E01, + epS02E02, + epS02E03, + epS00E08, + ) + + @Test + fun `Basic tests`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 3, targetId = epS02E03.id, list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(2, index) + } + findIndexByNumberOrId(targetNum = 100, targetId = UUID.randomUUID(), list = BlockingList.of(episodes)).let { index -> + Assert.assertEquals(0, index) + } + } + + @Test + fun `Test last index with zero start`() = + runTest { + val episodes = + BlockingList.of( + listOf( + create(0, 2), + epS02E01, + epS02E02, + epS02E03, + epS02E04, + epS02E05, + epS02E06, + ), + ) + findIndexByNumberOrId( + targetNum = 6, + targetId = epS02E06.id, + list = BlockingList.of(episodes), + ).let { index -> + Assert.assertEquals(6, index) + } + } + + @Test + fun `Test missing episodes`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 4, targetId = epS02E04.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 5, targetId = epS02E05.id, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test missing episodes without ID`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 4, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 5, targetId = null, list = BlockingList.of(missingEpisodes)).let { index -> + Assert.assertEquals(0, index) + } + } + + @Test + fun `Test with special`() = + runTest { + findIndexByNumberOrId(targetNum = 1, targetId = epS02E01.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(0, index) + } + findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(1, index) + } + findIndexByNumberOrId(targetNum = 3, targetId = epS02E03.id, list = BlockingList.of(withSpecials)).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test with special matching number`() = + runTest { + val list = listOf(epS02E01, epS00E02, epS02E02) + findIndexByNumberOrId( + targetNum = 2, + targetId = epS02E02.id, + list = BlockingList.of(list), + parentIndex = 2, + ).let { index -> + Assert.assertEquals(2, index) + } + } + + @Test + fun `Test with special in middle`() = + runTest { + val episodes = + BlockingList.of<BaseItem?>( + listOf( + epS02E01, + epS02E02, + epS02E03, + create(6, 0), + epS02E04, + epS02E05, + epS02E06, + ), + ) + + findIndexByNumberOrId( + targetNum = 3, + targetId = epS02E03.id, + list = episodes, + parentId = season2Id, + ).let { index -> + Assert.assertEquals(2, index) + } + + findIndexByNumberOrId( + targetNum = 4, + targetId = epS02E04.id, + list = episodes, + parentId = season2Id, + ).let { index -> + Assert.assertEquals(4, index) + } + } + + @Test + fun `Test with non-matching parent ID`() = + runTest { + val altSeason2Id = UUID.randomUUID() + Assert.assertNotEquals(altSeason2Id, season2Id) + Assert.assertNotEquals(altSeason2Id, epS02E02.data.parentId) + + val list = listOf(epS02E01, epS02E02) + findIndexByNumberOrId( + targetNum = 2, + targetId = epS02E02.id, + list = BlockingList.of(list), + parentId = altSeason2Id, + parentIndex = 2, + ).let { index -> + Assert.assertEquals(1, index) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt new file mode 100644 index 00000000..556a2fc0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt @@ -0,0 +1,415 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.datastore.core.DataStore +import com.github.damontecres.wholphin.MainActivityViewModel +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TestMainActivityViewModel { + private val testDispatcher = StandardTestDispatcher() + + private val context: Context = mockk() + private val preferences: DataStore<AppPreferences> = mockk() + private val serverRepository: ServerRepository = mockk() + private val setupNavigationManager: SetupNavigationManager = mockk(relaxed = true) + private val navigationManager: NavigationManager = mockk(relaxed = true) + private val deviceProfileService: DeviceProfileService = mockk() + private val backdropService: BackdropService = mockk(relaxed = true) + private val appUpgradeHandler: AppUpgradeHandler = mockk() + private val intentService: IntentService = mockk() + private val serverDao: JellyfinServerDao = mockk() + + val viewModel = + MainActivityViewModel( + context, + preferences, + serverRepository, + setupNavigationManager, + navigationManager, + deviceProfileService, + backdropService, + appUpgradeHandler, + intentService, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + every { appUpgradeHandler.needUpgrade() } returns false + every { appUpgradeHandler.copySubfont(any()) } returns Unit + every { serverRepository.serverDao } returns serverDao + coEvery { serverDao.getServer(serverId) } returns + JellyfinServerUsers( + currentUser.server, + // User list is unused + emptyList(), + ) + mockkStatic(Toast::class) + val mockToast = mockk<Toast>() + every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast + every { mockToast.show() } returns Unit + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkStatic(Toast::class) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { preferences.data } returns + flow { + emit( + AppPreferences.getDefaultInstance().update(block), + ) + } + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + viewModel.appStart(null) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + } + + @Test + fun `Test auto sign disabled in with current server`() = + runTest { + setupPreferences { + signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + viewModel.appStart(null) + advanceUntilIdle() + + verify(exactly = 1) { serverDao.getServer(serverId) } + coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) } + val args = mutableListOf<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(args)) } + assertEquals(2, args.size) + args[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getServer(serverId) } + val args = mutableListOf<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(args)) } + assertEquals(2, args.size) + args[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) + coEvery { serverRepository.restoreSession(null, null) } returns null + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 1) { serverRepository.restoreSession(null, null) } + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } + + @Test + fun `Test intent result is noop`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + } + + @Test + fun `Test intent result is error, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test intent result is error, does not have server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } + + @Test + fun `Test intent result is target, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + // Note: navigation manager internally handles Home destination, so it won't be in this list + assertEquals(1, backstack.size) + backstack[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + } + } + + @Test + fun `Test intent result is target, has server, no home backstack`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = false, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + assertEquals(1, backstack.size) + backstack[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + } + } + + @Test + fun `Test intent result is target, has no server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf<Destination>() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot<SetupDestination>() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt new file mode 100644 index 00000000..b5dfa5c3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt @@ -0,0 +1,74 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.services.SeerrUserConfig +import org.junit.Assert +import org.junit.Test + +class TestSeerrPermission { + @Test + fun `Is Admin`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 2, + ) + Assert.assertTrue(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Request any 4k has request movie 4k`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 1024, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Request movie 4k and does not have request tv 4k`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 2048, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Has explicit request 4k and children`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 7168, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K)) + } + + @Test + fun `Has explicit request 4k children`() { + val user = + SeerrUserConfig( + id = 0, + permissions = 6144, + ) + Assert.assertFalse(user.hasPermission(SeerrPermission.ADMIN)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)) + Assert.assertTrue(user.hasPermission(SeerrPermission.REQUEST_4K_TV)) + Assert.assertFalse(user.hasPermission(SeerrPermission.REQUEST_4K)) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt new file mode 100644 index 00000000..a98440f4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -0,0 +1,356 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.TrackSelectionParameters +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelected +import com.github.damontecres.wholphin.ui.playback.TrackSelectionResult +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import org.jellyfin.sdk.model.api.MediaStreamType +import org.junit.Assert +import org.junit.Test + +class TestSelectionTrackExamples { + private fun runTest( + builder: TestTracks.Builder, + playerBackend: PlayerBackend, + audioIndex: Int, + subtitleIndex: Int, + onResult: (TrackSelectionResult) -> Unit, + ) { + val testTacks = + when (playerBackend) { + PlayerBackend.EXO_PLAYER -> builder.buildForExoPlayer() + PlayerBackend.MPV -> builder.buildForMpv() + else -> throw IllegalArgumentException("Unsupported backend: $playerBackend") + } + + // These checks help check for invalid inputs + val audioTrack = testTacks.tracks.filter { it.index == audioIndex } + Assert.assertEquals(1, audioTrack.size) + Assert.assertEquals(MediaStreamType.AUDIO, audioTrack.first().type) + val subtitleTrack = testTacks.tracks.filter { it.index == subtitleIndex } + Assert.assertEquals(1, subtitleTrack.size) + Assert.assertEquals(MediaStreamType.SUBTITLE, subtitleTrack.first().type) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTacks.toTracks(), + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + source = builder.buildMediaSourceInfo(), + ).also(onResult) + } + + @Test + fun `test VAS Exo`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.EXO_PLAYER, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test VAS MPV`() { + runTest(TrackExamples.builderVAASSS, PlayerBackend.MPV, audioIndex = 1, subtitleIndex = 4) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS Exo`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("0:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.EXO_PLAYER, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test EVAS MPV`() { + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 4, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderEVAASSS, + PlayerBackend.MPV, + audioIndex = 2, + subtitleIndex = 0, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test builderVASASS`() { + runTest( + TrackExamples.builderVASASS, + PlayerBackend.EXO_PLAYER, + audioIndex = 1, + subtitleIndex = 2, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.MPV, + audioIndex = 1, + subtitleIndex = 2, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("2:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.EXO_PLAYER, + audioIndex = 3, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("6", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + runTest( + TrackExamples.builderVASASS, + PlayerBackend.MPV, + audioIndex = 3, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5:3", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `Test external subtitles at end`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(2) + .addExternalSubtitle(1) + + runTest( + builder, + PlayerBackend.EXO_PLAYER, + audioIndex = 1, + subtitleIndex = 5, // external + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test AAVASS in issue 1005`() { + // https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 + val builder = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addAudio() + .addSubtitle(42) + runTest( + builder, + PlayerBackend.MPV, + audioIndex = 1, + subtitleIndex = 5, + ) { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test unsupported tracks`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_UNSUPPORTED_TYPE) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio) + Assert.assertFalse(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 4, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + } + + @Test + fun `test exceeds capabilities tracks`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_EXCEEDS_CAPABILITIES) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio) + Assert.assertFalse(result.bothSelected) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = builder.buildForExoPlayer().toTracks(), + audioIndex = 4, + subtitleIndex = null, + source = builder.buildMediaSourceInfo(), + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + } + + @Test + fun `test exo track IDs with slashes`() { + val testTracks = + TestTracks( + listOf( + // .ts files (eg recorded TV) report IDs like this in ExoPlayer + TestTrack("3/49", 0, MediaStreamType.VIDEO, false), + TestTrack("3/52", 1, MediaStreamType.AUDIO, false), + TestTrack("", 2, MediaStreamType.DATA, false), + TestTrack("3/53", 3, MediaStreamType.AUDIO, false), + ), + ) + val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addTrack(1, MediaStreamType.DATA, false, C.FORMAT_HANDLED) + .addAudio() + .buildMediaSourceInfo() + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 1, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 2, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio) + Assert.assertFalse(result.bothSelected) + } + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = testTracks.toTracks(), + audioIndex = 3, + subtitleIndex = null, + source = mediaSource, + ).let { result -> + Assert.assertEquals(TrackSelected.SELECTED, result.audio) + Assert.assertTrue(result.bothSelected) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt new file mode 100644 index 00000000..fec67c6b --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.InterfacePreferences +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.ui.preferences.subtitle.shouldEnableSeparateHdrToggle +import org.junit.Assert +import org.junit.Test + +class TestSubtitleHdrMigration { + private fun subtitles(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + block.invoke(this) + }.build() + + private fun shouldSeparate( + sdr: SubtitlePreferences, + hdr: SubtitlePreferences, + ): Boolean = + InterfacePreferences + .newBuilder() + .apply { + subtitlesPreferences = sdr + hdrSubtitlesPreferences = hdr + }.build() + .shouldEnableSeparateHdrToggle() + + @Test + fun `Test separate enabled`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { fontSize = 30 } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertTrue(shouldSeparate) + } + + @Test + fun `Test separate disabled`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { fontSize = 60 } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertFalse(shouldSeparate) + } + + @Test + fun `Test HDR unchanged`() { + val sdr = subtitles { fontSize = 60 } + val differentHdr = subtitles { } + val shouldSeparate = shouldSeparate(sdr, differentHdr) + Assert.assertFalse(shouldSeparate) + } +} 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 index 5027aee8..90b45182 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -2,7 +2,6 @@ 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 @@ -264,32 +263,3 @@ class TestSuggestionsCombineAndDeduplicate( } } } - -// 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/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt index cad496ca..41db76aa 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -235,22 +235,153 @@ class TestTrackSelection { return Tracks(groups) } - private fun TrackSelectionParameters.getAudioOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_AUDIO) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + private fun buildMissingIdTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (3..5).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (6..8).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) } - } - return null + val groups = + formats + .map { TrackGroup(it) } + .map { + Tracks.Group( + it, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + } + return Tracks(groups) } - private fun TrackSelectionParameters.getSubtitleOverride(): Format? { - this.overrides.forEach { (trackGroup, trackSelectionOverride) -> - if (trackGroup.type == C.TRACK_TYPE_TEXT) { - return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + private fun buildDifferentOrderedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:2") + .setSampleMimeType("audio/default") + .build(), + Format + .Builder() + .setId("0:4") + .setSampleMimeType("audio/default") + .build(), + Format + .Builder() + .setId("0:3") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + (5..8).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("1:e:8") // External + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) } - } - return null + val groups = + formats + .map { TrackGroup(it) } + .map { + Tracks.Group( + it, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(false), + ) + } + return Tracks(groups) } @Test @@ -268,8 +399,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = 4, source = source, @@ -283,8 +412,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 4, source = source, @@ -298,8 +425,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = TrackIndex.DISABLED, source = source, @@ -325,8 +450,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = 4, source = source, @@ -340,8 +463,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 4, source = source, @@ -355,8 +476,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 6, source = source, @@ -370,8 +489,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 1, subtitleIndex = TrackIndex.DISABLED, source = source, @@ -397,8 +514,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 0, source = source, @@ -412,8 +527,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -439,8 +552,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 2, subtitleIndex = 0, source = source, @@ -466,8 +577,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -483,8 +592,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.MPV, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 5, source = source, @@ -518,8 +625,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 0, source = source, @@ -541,8 +646,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 5, source = source, @@ -569,8 +672,6 @@ class TestTrackSelection { .createTrackSelections( trackSelectionParams = trackSelectionParameters, tracks = tracks, - playerBackend = PlayerBackend.EXO_PLAYER, - supportsDirectPlay = true, audioIndex = 3, subtitleIndex = 2, source = source, @@ -588,4 +689,129 @@ class TestTrackSelection { } } } + + @Test + fun `test ExoPlayer missing ids`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString<MediaSourceInfo>(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.EXO_PLAYER) + val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } } + Assert.assertTrue("0:1" in ids) + Assert.assertTrue("0:2" !in ids) + Assert.assertTrue("0:3" in ids) + + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV missing ids`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString<MediaSourceInfo>(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.MPV) + val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } } + println(ids) + + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer different order`() { + val source = + TestTracks + .Builder() + .addVideo() + .addAudio(3) + .addSubtitle(4) + .addExternalSubtitle(1) + .buildMediaSourceInfo() + val tracks = buildDifferentOrderedTracks(PlayerBackend.EXO_PLAYER) + + Assert.assertEquals(9, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 1, + subtitleIndex = 8, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals( + "1:e:8", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + audioIndex = 3, + subtitleIndex = 8, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals( + "1:e:8", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } +} + +internal fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null +} + +internal fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt new file mode 100644 index 00000000..7370f29e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -0,0 +1,595 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks +import org.jellyfin.sdk.model.api.MediaProtocol +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaSourceType +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamProtocol +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import org.junit.Assert +import org.junit.Test + +/** + * Represents a list of video, audio or, subtitle tracks in a file when direct playing + * + * @see Builder + */ +class TestTracks( + val tracks: List<TestTrack> = emptyList(), +) { + /** + * Convert to media3 [Tracks] + */ + fun toTracks(): Tracks { + val groups = + tracks + .sortedBy { it.external } + .map { + val mimeType = + when (it.type) { + MediaStreamType.VIDEO -> "video/sample" + MediaStreamType.AUDIO -> "audio/default" + MediaStreamType.SUBTITLE -> "text/sample" + MediaStreamType.DATA -> MimeTypes.APPLICATION_ID3 + else -> throw UnsupportedOperationException("${it.type}") + } + val format = + Format + .Builder() + .setId(it.id) + .setSampleMimeType(mimeType) + .build() + Tracks.Group( + TrackGroup(format), + false, + intArrayOf(it.support), + booleanArrayOf(false), + ) + } + + return Tracks(groups) + } + + /** + * Builder for adding tracks + */ + class Builder { + private val tracks = mutableListOf<TestTrackBuilder>() + + fun addVideo(): Builder = addTrack(1, MediaStreamType.VIDEO, false, C.FORMAT_HANDLED) + + fun addAudio(count: Int = 1): Builder = addTrack(count, MediaStreamType.AUDIO, false, C.FORMAT_HANDLED) + + fun addSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, false, C.FORMAT_HANDLED) + + fun addExternalSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, true, C.FORMAT_HANDLED) + + fun addEmpty(count: Int = 1): Builder = addTrack(count, null, false, C.FORMAT_UNSUPPORTED_TYPE) + + fun addTrack( + count: Int, + type: MediaStreamType?, + external: Boolean, + @C.FormatSupport support: Int, + ): Builder { + repeat(count) { + tracks.add(TestTrackBuilder(type, external, support)) + } + return this + } + + /** + * Create the [TestTracks] as if being playing by ExoPlayer + */ + fun buildForExoPlayer(): TestTracks { + // Format is: [<file index>:][e:]<track index>, "e:" indicates external subtitle + val hasExternal = tracks.firstOrNull { it.external } != null + val testTracks = + buildList { + var externalSubCount = 0 + val t = + tracks + .mapIndexedNotNull { index, track -> + if (track.type == null) { + return@mapIndexedNotNull null + } + if (track.external) { + externalSubCount++ + TestTrack( + "$externalSubCount:e:$index", + index, + track.type, + track.external, + track.support, + ) + } else if (hasExternal) { + val idx = index + 1 - externalSubCount + // If there's a sidecar file, the actual file + TestTrack( + "0:$idx", + index, + track.type, + track.external, + track.support, + ) + } else { + val idx = index + 1 + TestTrack( + "$idx", + index, + track.type, + track.external, + track.support, + ) + } + } + addAll(t) + } + return TestTracks(testTracks) + } + + /** + * Create the [TestTracks] as if being playing by MPV + */ + fun buildForMpv(): TestTracks { + val embeddedCount = tracks.count { !it.external } + val embeddedSubtitleCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external } + val externalSubCount = + tracks.count { it.type == MediaStreamType.SUBTITLE && it.external } +// val firstNonExternal = tracks.indexOfFirst { it.type != MediaStreamType.SUBTITLE } + val firstExternal = + tracks.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.external } + val externalSubtitleAreFirst = externalSubCount > 0 && firstExternal == 0 + var videoCount = 0 + var audioCount = 0 + var subtitleCount = 0 + val testTracks = + tracks.mapIndexedNotNull { index, track -> + if (track.type == null) { + return@mapIndexedNotNull null + } + val id = + if (track.external) { + // MPV places external subtitles last + val idx = index + if (externalSubtitleAreFirst) embeddedCount else 0 + subtitleCount++ + val count = + subtitleCount + if (externalSubtitleAreFirst) embeddedSubtitleCount else 0 + "$idx:e:$count" + } else { + val idx = + if (externalSubtitleAreFirst) index - externalSubCount else index + when (track.type) { + MediaStreamType.AUDIO -> { + audioCount++ + "$idx:$audioCount" + } + + MediaStreamType.VIDEO -> { + videoCount++ + "$idx:$videoCount" + } + + MediaStreamType.SUBTITLE -> { + subtitleCount++ + val count = + subtitleCount - (if (externalSubtitleAreFirst) externalSubCount else 0) + "$idx:$count" + } + + else -> { + throw IllegalArgumentException("Unsupported type " + track.type) + } + } + } + TestTrack(id, index, track.type, track.external) + } + return TestTracks(testTracks) + } + + fun buildMediaSourceInfo(): MediaSourceInfo { + val streams = + tracks + .filter { it.type != null } + .mapIndexed { index, track -> + MediaStream( + index = index, + type = track.type!!, + isExternal = track.external, + deliveryMethod = + if (track.type == MediaStreamType.SUBTITLE && track.external) { + SubtitleDeliveryMethod.EXTERNAL + } else { + SubtitleDeliveryMethod.EMBED + }, + codec = null, + language = null, + isInterlaced = false, + isDefault = false, + isForced = false, + isHearingImpaired = false, + isTextSubtitleStream = false, + supportsExternalStream = false, + ) + } + val source = + MediaSourceInfo( + mediaStreams = streams, + protocol = MediaProtocol.HTTP, + type = MediaSourceType.DEFAULT, + isRemote = false, + readAtNativeFramerate = false, + ignoreDts = false, + ignoreIndex = false, + genPtsInput = false, + supportsTranscoding = true, + supportsDirectStream = true, + supportsDirectPlay = true, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + requiresLooping = false, + supportsProbing = false, + transcodingSubProtocol = MediaStreamProtocol.HLS, + hasSegments = false, + ) + return source + } + } + + companion object { + fun fromMediaSourceInfo(source: MediaSourceInfo): TestTracks.Builder = + TestTracks.Builder().apply { + source.mediaStreams.orEmpty().forEach { + when (it.type) { + MediaStreamType.AUDIO -> addAudio() + MediaStreamType.VIDEO -> addVideo() + MediaStreamType.SUBTITLE -> if (it.isExternal) addExternalSubtitle() else addSubtitle() + else -> throw IllegalArgumentException("Unsupported type ${it.type}") + } + } + } + } +} + +data class TestTrack( + val id: String, + val index: Int, + val type: MediaStreamType, + val external: Boolean, + @param:C.FormatSupport val support: Int = C.FORMAT_HANDLED, +) + +class TestTrackBuilder( + val type: MediaStreamType?, + val external: Boolean = false, + @param:C.FormatSupport val support: Int = C.FORMAT_HANDLED, +) + +fun assertIdType( + expectedId: String, + expectedType: MediaStreamType, + track: TestTrack, +) { + Assert.assertEquals(expectedId, track.id) + Assert.assertEquals(expectedType, track.type) +} + +/** + * Varies [TestTracks.Builder] examples + * + * They are named by the order of the tracks, V=video, A=audio, S=subtitle, E=external subtitle + */ +object TrackExamples { + val builderVAASSS = + TestTracks + .Builder() + .addVideo() + .addAudio(2) + .addSubtitle(3) + + val builderAASSSV = + TestTracks + .Builder() + .addAudio(2) + .addSubtitle(3) + .addVideo() + + val builderAAVSSS = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addSubtitle(3) + + val builderVASASS = + TestTracks + .Builder() + .addVideo() + .addAudio(1) + .addSubtitle(1) + .addAudio(1) + .addSubtitle(2) + + val builderEVAASSS = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(2) + .addSubtitle(3) +} + +class TestTracksTests { + @Test + fun testMpv() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForMpv() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("9:e:5", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("1:1", testTracks[2].id) + Assert.assertEquals("2:2", testTracks[3].id) + Assert.assertEquals("3:3", testTracks[4].id) + Assert.assertEquals("4:4", testTracks[5].id) + Assert.assertEquals("5:1", testTracks[6].id) + Assert.assertEquals("6:2", testTracks[7].id) + Assert.assertEquals("7:3", testTracks[8].id) + Assert.assertEquals("8:4", testTracks[9].id) + } + + @Test + fun `test ExoPlayer with external`() { + val testTracks = + TestTracks + .Builder() + .addExternalSubtitle() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(10, testTracks.size) + Assert.assertEquals("1:e:0", testTracks[0].id) + Assert.assertEquals("0:1", testTracks[1].id) + Assert.assertEquals("0:2", testTracks[2].id) + Assert.assertEquals("0:3", testTracks[3].id) + Assert.assertEquals("0:4", testTracks[4].id) + Assert.assertEquals("0:5", testTracks[5].id) + Assert.assertEquals("0:6", testTracks[6].id) + Assert.assertEquals("0:7", testTracks[7].id) + Assert.assertEquals("0:8", testTracks[8].id) + Assert.assertEquals("0:9", testTracks[9].id) + } + + @Test + fun `test ExoPlayer without external`() { + val testTracks = + TestTracks + .Builder() + .addVideo() + .addAudio(4) + .addSubtitle(4) + .buildForExoPlayer() + .tracks + Assert.assertEquals(9, testTracks.size) + Assert.assertEquals("1", testTracks[0].id) + Assert.assertEquals("2", testTracks[1].id) + Assert.assertEquals("3", testTracks[2].id) + Assert.assertEquals("4", testTracks[3].id) + Assert.assertEquals("5", testTracks[4].id) + Assert.assertEquals("6", testTracks[5].id) + Assert.assertEquals("7", testTracks[6].id) + Assert.assertEquals("8", testTracks[7].id) + Assert.assertEquals("9", testTracks[8].id) + } + + @Test + fun `test original-VAS`() { + TrackExamples.builderVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + Assert.assertEquals("1", exo[0].id) + Assert.assertEquals("2", exo[1].id) + Assert.assertEquals("3", exo[2].id) + Assert.assertEquals("4", exo[3].id) + Assert.assertEquals("5", exo[4].id) + Assert.assertEquals("6", exo[5].id) + } + + TrackExamples.builderVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + Assert.assertEquals("0:1", mpv[0].id) + Assert.assertEquals("1:1", mpv[1].id) + Assert.assertEquals("2:2", mpv[2].id) + Assert.assertEquals("3:1", mpv[3].id) + Assert.assertEquals("4:2", mpv[4].id) + Assert.assertEquals("5:3", mpv[5].id) + } + } + + @Test + fun `test VAS with external`() { + TrackExamples.builderEVAASSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(7, exo.size) + Assert.assertTrue(exo[0].external) + assertIdType("1:e:0", MediaStreamType.SUBTITLE, exo[0]) + assertIdType("0:1", MediaStreamType.VIDEO, exo[1]) + assertIdType("0:2", MediaStreamType.AUDIO, exo[2]) + assertIdType("0:3", MediaStreamType.AUDIO, exo[3]) + assertIdType("0:4", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("0:5", MediaStreamType.SUBTITLE, exo[5]) + assertIdType("0:6", MediaStreamType.SUBTITLE, exo[6]) + } + + TrackExamples.builderEVAASSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(7, mpv.size) + Assert.assertTrue(mpv[0].external) + Assert.assertEquals("6:e:4", mpv[0].id) + Assert.assertEquals("0:1", mpv[1].id) + Assert.assertEquals("1:1", mpv[2].id) + Assert.assertEquals("2:2", mpv[3].id) + Assert.assertEquals("3:1", mpv[4].id) + Assert.assertEquals("4:2", mpv[5].id) + Assert.assertEquals("5:3", mpv[6].id) + } + } + + @Test + fun `test ASV`() { + TrackExamples.builderAASSSV.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.VIDEO, exo[5]) + } + + TrackExamples.builderAASSSV.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:3", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:1", MediaStreamType.VIDEO, mpv[5]) + } + } + + @Test + fun `test AVS`() { + TrackExamples.builderAAVSSS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.SUBTITLE, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderAAVSSS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:1", MediaStreamType.SUBTITLE, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } + + @Test + fun `test VASASS`() { + TrackExamples.builderVASASS.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(6, exo.size) + assertIdType("1", MediaStreamType.VIDEO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.SUBTITLE, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + TrackExamples.builderVASASS.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(6, mpv.size) + assertIdType("0:1", MediaStreamType.VIDEO, mpv[0]) + assertIdType("1:1", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.SUBTITLE, mpv[2]) + assertIdType("3:2", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:2", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) + } + } + + @Test + fun `Test external subtitles at end`() { + val builder = + TestTracks + .Builder() + .addVideo() + .addAudio(3) + .addSubtitle(4) + .addExternalSubtitle() + + builder.buildForExoPlayer().tracks.let { tracks -> + Assert.assertEquals(9, tracks.size) + Assert.assertEquals("0:1", tracks[0].id) + Assert.assertEquals("0:2", tracks[1].id) + Assert.assertEquals("0:3", tracks[2].id) + Assert.assertEquals("0:4", tracks[3].id) + Assert.assertEquals("0:5", tracks[4].id) + Assert.assertEquals("0:6", tracks[5].id) + Assert.assertEquals("0:7", tracks[6].id) + Assert.assertEquals("0:8", tracks[7].id) + Assert.assertEquals("1:e:8", tracks[8].id) + } + + builder.buildForMpv().tracks.let { tracks -> + Assert.assertEquals(9, tracks.size) + Assert.assertEquals("0:1", tracks[0].id) + Assert.assertEquals("1:1", tracks[1].id) + Assert.assertEquals("2:2", tracks[2].id) + Assert.assertEquals("3:3", tracks[3].id) + Assert.assertEquals("4:1", tracks[4].id) + Assert.assertEquals("5:2", tracks[5].id) + Assert.assertEquals("6:3", tracks[6].id) + Assert.assertEquals("7:4", tracks[7].id) + Assert.assertEquals("8:e:5", tracks[8].id) + } + + builder.buildMediaSourceInfo().let { source -> + val index = + source.mediaStreams?.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.isExternal } + Assert.assertEquals(8, index) + } + } + + @Test + fun `test AAVASS in issue 1005`() { + // https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175 + val builder = + TestTracks + .Builder() + .addAudio(2) + .addVideo() + .addAudio() + .addSubtitle(42) + + builder.buildForExoPlayer().tracks.let { exo -> + Assert.assertEquals(46, exo.size) + assertIdType("1", MediaStreamType.AUDIO, exo[0]) + assertIdType("2", MediaStreamType.AUDIO, exo[1]) + assertIdType("3", MediaStreamType.VIDEO, exo[2]) + assertIdType("4", MediaStreamType.AUDIO, exo[3]) + assertIdType("5", MediaStreamType.SUBTITLE, exo[4]) + assertIdType("6", MediaStreamType.SUBTITLE, exo[5]) + } + + builder.buildForMpv().tracks.let { mpv -> + Assert.assertEquals(46, mpv.size) + assertIdType("0:1", MediaStreamType.AUDIO, mpv[0]) + assertIdType("1:2", MediaStreamType.AUDIO, mpv[1]) + assertIdType("2:1", MediaStreamType.VIDEO, mpv[2]) + assertIdType("3:3", MediaStreamType.AUDIO, mpv[3]) + assertIdType("4:1", MediaStreamType.SUBTITLE, mpv[4]) + assertIdType("5:2", MediaStreamType.SUBTITLE, mpv[5]) + assertIdType("6:3", MediaStreamType.SUBTITLE, mpv[6]) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt new file mode 100644 index 00000000..34d962da --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -0,0 +1,151 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.ibm.icu.impl.Assert +import io.mockk.MockKMatcherScope +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.jellyfin.sdk.model.api.UserDto +import org.junit.Assert.assertNotNull +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() } + +/** + * Create a simple [BaseItemDto] movie + */ +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, + ) + +/** + * Create a simple [BaseItemDto] tv episode + */ +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, + ) + +/** + * Create a simple [BaseItemDto] song + */ +fun song( + id: UUID = UUID.randomUUID(), + albumId: UUID? = null, + name: String = "Test Song", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.AUDIO, + name = name, + albumId = albumId, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] playlist + */ +fun playlist( + id: UUID = UUID.randomUUID(), + name: String = "Test Playlist", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.PLAYLIST, + name = name, + seriesId = null, + genreItems = genres, + ) + +/** + * Create a simple [BaseItemDto] of the specified type + */ +fun item( + type: BaseItemKind, + id: UUID = UUID.randomUUID(), + name: String = "Test $type", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = type, + name = name, + seriesId = null, + genreItems = genres, + ) + +fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + +fun user( + serverId: UUID, + userId: UUID = UUID.randomUUID(), +) = JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = null, +) + +fun userDto(userId: UUID) = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + +fun currentUser( + serverId: UUID = UUID.randomUUID(), + userId: UUID = UUID.randomUUID(), +) = CurrentUser( + server(serverId), + user(userId), +) + +/** + * Asserts that the object is not null and is type `T` + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +inline fun <reified T : Any> assertIs(obj: Any?) { + contract { + returns() implies (obj is T) + } + assertNotNull(obj) + val result = T::class.isInstance(obj) + if (!result) { + Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ") + } +} 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 index 027afb98..2e2c3a89 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -27,18 +27,17 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset 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 @@ -105,11 +104,7 @@ class BasicUiTests { @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 + WholphinDispatchers.configure(TestModule.testDispatcher) hiltRule.inject() screensaverService = mockk(relaxed = true) @@ -130,7 +125,7 @@ class BasicUiTests { @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - Dispatchers.resetMain() + WholphinDispatchers.reset() } /** 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 index 8e43a4b0..c4a54ec2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -19,6 +19,7 @@ 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.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository @@ -35,7 +36,7 @@ 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 com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -46,7 +47,6 @@ 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 @@ -166,17 +166,6 @@ object TestModule { 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 @@ -236,7 +225,7 @@ object TestDatabaseModule { .allowMainThreadQueries() .setQueryCallback({ sqlQuery, args -> Timber.v("sqlQuery=$sqlQuery, args=$args") - }, Dispatchers.IO.asExecutor()) + }, WholphinDispatchers.IO.asExecutor()) .build() @Provides @@ -267,6 +256,10 @@ object TestDatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides @Singleton fun userPreferencesDataStore( 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 index d0bc8532..6d30a600 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -6,6 +6,8 @@ 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 +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult @OptIn(ExperimentalTestApi::class) fun SemanticsNodeInteraction.performClickEnter() = @@ -14,3 +16,8 @@ fun SemanticsNodeInteraction.performClickEnter() = } fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) + +fun successQueryResult( + items: List<BaseItemDto> = emptyList(), + totalRecordCount: Int = items.size, +) = successResponse(BaseItemDtoQueryResult(items, totalRecordCount, 0)) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt new file mode 100644 index 00000000..bc879d24 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -0,0 +1,202 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ServerRepository +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.services.DeletedItem +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +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.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.UUID + +/** + * Each endpoint is stubbed with a distinct total, so a returned index identifies which endpoint + * produced it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CollectionFolderViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockMediaManagementService = mockk<MediaManagementService>(relaxed = true) + private val mockUserLibraryApi = mockk<UserLibraryApi>() + + private val artistsRequest = slot<GetArtistsRequest>() + private val itemsRequest = slot<GetItemsRequest>() + + private val libraryId = UUID.randomUUID() + + private val library = + BaseItemDto( + id = libraryId, + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = CollectionType.MUSIC, + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + coEvery { mockUserLibraryApi.getItem(libraryId) } returns successResponse(library) + + // No current user, so no saved LibraryDisplayInfo is consulted + every { mockServerRepository.currentUser } returns null + every { mockMediaManagementService.deletedItemFlow } returns MutableSharedFlow<DeletedItem>() + + // mockkObject spies by default, so every handler must be stubbed or the real one runs + // against the relaxed ApiClient + mockkObject(GetItemsRequestHandler) + mockkObject(GetArtistsHandler) + mockkObject(GetPersonsHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns + successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT) + coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns + successQueryResult(totalRecordCount = ARTIST_COUNT) + coEvery { GetPersonsHandler.execute(mockApi, any()) } returns + successQueryResult(totalRecordCount = PERSON_COUNT) + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkObject(GetItemsRequestHandler) + unmockkObject(GetArtistsHandler) + unmockkObject(GetPersonsHandler) + } + + private fun createViewModel(filter: GetItemsFilter) = + CollectionFolderViewModel( + savedStateHandle = SavedStateHandle(), + api = mockApi, + context = mockk(relaxed = true), + serverRepository = mockServerRepository, + libraryDisplayInfoDao = mockk(relaxed = true), + favoriteWatchManager = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + navigationManager = mockk(relaxed = true), + themeSongPlayer = mockk(relaxed = true), + userPreferencesService = mockk(relaxed = true), + mediaManagementService = mockMediaManagementService, + musicService = mockk(relaxed = true), + streamChoiceService = mockk(relaxed = true), + serverReportService = mockk(relaxed = true), + filterOptionCache = mockk(relaxed = true), + itemId = libraryId.toString(), + initialSortAndDirection = null, + recursive = true, + collectionFilter = CollectionFolderFilter(filter = filter), + useSeriesForPrimary = false, + defaultViewOptions = ViewOptionsSquare, + ) + + /** Counting via /Items would return every song and album, overshooting the artist grid */ + @Test + fun `positionOfLetter counts artists for the ARTIST override`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(ARTIST_COUNT, position) + assertEquals("K", artistsRequest.captured.nameLessThan) + assertEquals(libraryId, artistsRequest.captured.parentId) + assertEquals(0, artistsRequest.captured.limit) + + // Counting library items rather than artists is the bug this guards against + coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } + } + + /** + * Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists + * like "The Beatles" even though the grid sorts them under "B". + */ + @Test + fun `artist pager requests SortName for alphabet highlighting`() = + runTest(testDispatcher) { + createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + + assertTrue(ItemFields.SORT_NAME in artistsRequest.captured.fields.orEmpty()) + } + + /** + * /Persons has no nameLessThan or startIndex, so counting up to a letter is not expressible. + * Falling back to /Items would count media rather than people, ie the same defect as the artist + * grid, so guard against it even though no screen currently sorts people by name. + */ + @Test + fun `positionOfLetter does not fall back to counting items for the PERSON override`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.PERSON)) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(null, position) + coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) } + coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) } + } + + @Test + fun `positionOfLetter counts items for the NONE override`() = + runTest(testDispatcher) { + val viewModel = + createViewModel( + GetItemsFilter( + override = GetItemsFilterOverride.NONE, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + ), + ) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(LIBRARY_ITEM_COUNT, position) + assertEquals("K", itemsRequest.captured.nameLessThan) + assertEquals(libraryId, itemsRequest.captured.parentId) + assertEquals(0, itemsRequest.captured.limit) + } + + companion object { + private const val LIBRARY_ITEM_COUNT = 7315 + private const val ARTIST_COUNT = 730 + private const val PERSON_COUNT = 12 + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt new file mode 100644 index 00000000..0b4cece1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt @@ -0,0 +1,427 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache +import com.github.damontecres.wholphin.services.MediaManagementService +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.RememberedTabService +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.item +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.util.DataLoadingState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.operations.ArtistsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.PersonsApi +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.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestFavoritesViewModel { + private val context: Context = mockk() + private val api: ApiClient = mockk() + private val navigationManager: NavigationManager = mockk() + private val serverRepository: ServerRepository = mockk() + private val libraryDisplayInfoDao: LibraryDisplayInfoDao = mockk() + private val favoriteWatchManager: FavoriteWatchManager = mockk() + private val backdropService: BackdropService = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + private val mediaManagementService: MediaManagementService = mockk() + private val streamChoiceService: StreamChoiceService = mockk() + private val serverReportService: ServerReportService = mockk() + private val filterOptionCache: FilterOptionCache = mockk() + private val rememberedTabService: RememberedTabService = mockk() + private val navDrawerService: NavDrawerService = mockk() + + private val itemsApi: ItemsApi = mockk() + private val artistsApi: ArtistsApi = mockk() + private val personsApi: PersonsApi = mockk() + + @Before + fun setup() { + every { api.itemsApi } returns itemsApi + every { api.artistsApi } returns artistsApi + every { api.personsApi } returns personsApi + every { navDrawerService.state } returns + MutableStateFlow( + NavDrawerItemState( + allLibraries = + listOf( + library(CollectionType.MOVIES), + library(CollectionType.TVSHOWS), + library(CollectionType.BOXSETS), + ), + ), + ) + } + + private fun createViewModel(): FavoritesViewModel = + FavoritesViewModel( + context = context, + api = api, + navigationManager = navigationManager, + serverRepository = serverRepository, + libraryDisplayInfoDao = libraryDisplayInfoDao, + favoriteWatchManager = favoriteWatchManager, + backdropService = backdropService, + userPreferencesService = userPreferencesService, + mediaManagementService = mediaManagementService, + streamChoiceService = streamChoiceService, + serverReportService = serverReportService, + filterOptionCache = filterOptionCache, + rememberedTabService = rememberedTabService, + navDrawerService = navDrawerService, + ).apply { + setupPossibleTypes() + } + + private val items = listOf(BaseItem(movie())) + + private val empty = emptyList<BaseItem>() + + private fun successJob(items: List<BaseItem>) = CompletableDeferred(DataLoadingState.Success(items)) + + private fun errorJob() = CompletableDeferred(DataLoadingState.Error()) + + private suspend fun testInitialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>, + asserts: (FavoritesPageState) -> Unit, + ) { + createViewModel().let { viewModel -> + viewModel.initialFetchAndWait( + firstTabKey = firstTabKey, + jobs = jobs, + ) + viewModel.state.value.let { state -> + asserts.invoke(state) + } + } + } + + @Test + fun `Test tabKey for first success`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(items), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + } + + @Test + fun `Test tabKey for second success`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for first failure`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to errorJob(), + BaseItemKind.SERIES to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(items), + BaseItemKind.EPISODE to successJob(empty), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.MOVIE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.EPISODE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.SERIES, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty with first error`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to errorJob(), + BaseItemKind.SERIES to successJob(empty), + BaseItemKind.EPISODE to successJob(items), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.SERIES, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.MOVIE, state.tabKey) + } + } + + @Test + fun `Test tabKey for multiple empty with first empty`() = + runTest { + val jobs = + mapOf( + BaseItemKind.MOVIE to successJob(empty), + BaseItemKind.SERIES to successJob(empty), + BaseItemKind.EPISODE to successJob(empty), + BaseItemKind.BOX_SET to successJob(items), + ) + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.EPISODE, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.BOX_SET, state.tabKey) + } + + testInitialFetchAndWait( + firstTabKey = BaseItemKind.BOX_SET, + jobs = jobs, + ) { state -> + assertEquals(BaseItemKind.BOX_SET, state.tabKey) + } + } + + @Test + fun `Test fetchType basic`() = + runTest { + val movie = movie() + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(movie)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(movie), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MOVIE in state.tabs) + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType with error`() = + runTest { + val ex = Exception() + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } throws ex + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Error) + assertEquals(ex, (data as DataLoadingState.Error).exception) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MOVIE in state.tabs) + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Error) + assertEquals(ex, (st.items as DataLoadingState.Error).exception) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType with empty result does not add tab`() = + runTest { + coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult() + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MOVIE, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(0, (data as DataLoadingState.Success).data.size) + viewModel.state.value.let { state -> + assertFalse(BaseItemKind.MOVIE in state.tabs) + + assertTrue(BaseItemKind.MOVIE in state.favorites) + state.favorites[BaseItemKind.MOVIE]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + assertEquals(0, (st.items as DataLoadingState.Success).data.size) + } + } + coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) } + } + + @Test + fun `Test fetchType for artists`() = + runTest { + val item = item(BaseItemKind.MUSIC_ARTIST) + coEvery { artistsApi.getArtists(any<GetArtistsRequest>()) } returns successQueryResult(listOf(item)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.MUSIC_ARTIST, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.MUSIC_ARTIST in state.tabs) + assertTrue(BaseItemKind.MUSIC_ARTIST in state.favorites) + state.favorites[BaseItemKind.MUSIC_ARTIST]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { artistsApi.getArtists(any<GetArtistsRequest>()) } + coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) } + coVerify(exactly = 0) { personsApi.getPersons(any<GetPersonsRequest>()) } + } + + @Test + fun `Test fetchType for persons`() = + runTest { + val item = item(BaseItemKind.PERSON) + coEvery { personsApi.getPersons(any<GetPersonsRequest>()) } returns successQueryResult(listOf(item)) + + val viewModel = createViewModel() + val data = + viewModel.fetchType( + type = BaseItemKind.PERSON, + sortAndDirection = SortAndDirection.DEFAULT, + filter = GetItemsFilter(), + viewOptions = ViewOptions(), + ) + assertTrue(data is DataLoadingState.Success) + assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first()) + viewModel.state.value.let { state -> + assertTrue(BaseItemKind.PERSON in state.tabs) + assertTrue(BaseItemKind.PERSON in state.favorites) + state.favorites[BaseItemKind.PERSON]!!.let { st -> + assertTrue(st.item is DataLoadingState.Success) + assertTrue(st.items is DataLoadingState.Success) + } + } + coVerify(exactly = 1) { personsApi.getPersons(any<GetPersonsRequest>()) } + coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) } + coVerify(exactly = 0) { artistsApi.getArtists(any<GetArtistsRequest>()) } + } +} + +private fun library(type: CollectionType) = + Library( + itemId = UUID.randomUUID(), + name = "name-${type.serialName}", + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = type, + isRecordingFolder = false, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt new file mode 100644 index 00000000..cf7ee59f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -0,0 +1,860 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.ShowNextUpWhen +import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +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.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlayerCreation +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.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.MediaInfoApi +import org.jellyfin.sdk.api.operations.MediaSegmentsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult +import org.jellyfin.sdk.model.api.MediaSegmentType +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlaybackInfoResponse +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.jellyfin.sdk.model.extensions.ticks +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class PlaybackViewModelTests { + private val testDispatcher = StandardTestDispatcher() + + private val mockContext = mockk<Context>(relaxed = true) + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockNavigationManager = mockk<NavigationManager>(relaxed = true) + private val mockPlaylistCreator = mockk<PlaylistCreator>(relaxed = true) + private val mockItemPlaybackDao = mockk<ItemPlaybackDao>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockItemPlaybackRepository = mockk<ItemPlaybackRepository>(relaxed = true) + private val mockPlayerFactory = mockk<PlayerFactory>() + private val mockDatePlayedService = mockk<DatePlayedService>(relaxed = true) + private val mockDeviceInfo = mockk<DeviceInfo>(relaxed = true) + private val mockDeviceProfileService = mockk<DeviceProfileService>(relaxed = true) + private val mockRefreshRateService = mockk<RefreshRateService>(relaxed = true) + private val mockStreamChoiceService = mockk<StreamChoiceService>(relaxed = true) + private val mockUserPreferencesService = mockk<UserPreferencesService>(relaxed = true) + private val mockImageUrlService = mockk<ImageUrlService>(relaxed = true) + private val mockScreensaverService = mockk<ScreensaverService>(relaxed = true) + private val mockMusicService = mockk<MusicService>(relaxed = true) + + private val mockUserLibraryApi = mockk<UserLibraryApi>() + private val mockMediaInfoApi = mockk<MediaInfoApi>() + private val mockVideosApi = mockk<VideosApi>() + private val mockMediaSegmentsApi = mockk<MediaSegmentsApi>() + private val mockPlayer = mockk<Player>(relaxed = true) + + fun create(destination: Destination): PlaybackViewModel = + PlaybackViewModel( + context = mockContext, + api = mockApi, + navigationManager = mockNavigationManager, + playlistCreator = mockPlaylistCreator, + itemPlaybackDao = mockItemPlaybackDao, + serverRepository = mockServerRepository, + itemPlaybackRepository = mockItemPlaybackRepository, + playerFactory = mockPlayerFactory, + datePlayedService = mockDatePlayedService, + deviceInfo = mockDeviceInfo, + deviceProfileService = mockDeviceProfileService, + refreshRateService = mockRefreshRateService, + streamChoiceService = mockStreamChoiceService, + userPreferencesService = mockUserPreferencesService, + imageUrlService = mockImageUrlService, + screensaverService = mockScreensaverService, + musicService = mockMusicService, + destination = destination, + ) + + private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) { + val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block) + val prefs = UserPreferences(appPrefs, null) + coEvery { mockUserPreferencesService.getCurrent() } returns prefs + } + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val server = + JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + private val user = + JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = "1234", + ) + private val userDto = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + + private val mediaSource = + TestTracks + .Builder() + .addVideo() + .addAudio() + .addSubtitle() + .buildMediaSourceInfo() + + private val playSessionId = "playsessionid12345" + private val videoStreamUrl = "http://localhost:8096/video/stream" + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + + every { mockApi.userLibraryApi } returns mockUserLibraryApi + every { mockApi.mediaInfoApi } returns mockMediaInfoApi + every { mockApi.videosApi } returns mockVideosApi + every { mockApi.mediaSegmentsApi } returns mockMediaSegmentsApi + + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(mediaSource), + playSessionId = playSessionId, + ), + ) + + every { + mockVideosApi.getVideoStreamUrl( + itemId = any(), + mediaSourceId = mediaSource.id, + static = true, + tag = mediaSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + coEvery { mockServerRepository.currentUser } returns user + coEvery { mockServerRepository.currentUserDto } returns userDto + + coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null + coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns + PlayerCreation(mockPlayer) + every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) + + coEvery { + mockStreamChoiceService.chooseAudioStream( + source = mediaSource, + any(), + any(), + any(), + any(), + ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } + + coEvery { + mockStreamChoiceService.chooseSubtitleStream( + source = mediaSource, + any(), + any(), + any(), + any(), + any(), + ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + // Test data + val playlist = playlist() + val movie = movie(name = "Test Movie 1") + val movie2 = movie(name = "Test Movie 2") + val movie3 = movie(name = "Test Movie 3") + val intro = movie(name = "Test Intro") + val playlistItems = + Playlist.fromMedia(listOf(BaseItem(movie), BaseItem(movie2), BaseItem(movie3))) + val introSegmentMovie2 = + MediaSegmentDto( + id = UUID.randomUUID(), + itemId = movie2.id, + type = MediaSegmentType.INTRO, + startTicks = 5.seconds.inWholeTicks, + endTicks = 10.seconds.inWholeTicks, + ) + val segments = + MediaSegmentDtoQueryResult( + items = listOf(introSegmentMovie2), + totalRecordCount = 1, + startIndex = 0, + ) + // END test data + + /** + * Create the [PlaybackViewModel] and wait for its init job to complete + * + * Throws an exception if the job throws one + */ + @OptIn(InternalCoroutinesApi::class) + private suspend fun TestScope.createViewModel(destination: Destination): PlaybackViewModel { + val viewModel = create(destination) + testScheduler.advanceUntilIdle() + viewModel.initJob.join() + viewModel.initJob + .getCancellationException() + .cause + ?.let { throw it } + return viewModel + } + + @Test + fun `Play intro first`() = + runTest(testDispatcher) { + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + // Should be playing the intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + + @Test + fun `Play two intros first`() = + runTest(testDispatcher) { + val movie = movie() + val intro = movie() + val intro2 = movie() + setupPreferences { + cinemaMode = true + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro, intro2)) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + // Should be playing the first intro + Assert.assertEquals(intro.id.toString(), mediaItem.captured.mediaId) + val state = viewModel.state.value + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + val playlist = state.playlist.items + Assert.assertEquals(2, playlist.size) + Assert.assertEquals(intro.id, playlist[0].id) + Assert.assertEquals(intro2.id, playlist[1].id) + } + + @Test + fun `Don't play intro`() = + runTest(testDispatcher) { + val movie = movie() + setupPreferences { + cinemaMode = false + } + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + + val viewModel = createViewModel(Destination.Playback(movie.id, 0L)) + + coVerify(exactly = 0) { mockUserLibraryApi.getIntros(any(), any()) } + val mediaItem = slot<MediaItem>() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + viewModel.state.value.also { state -> + Assert.assertEquals(LoadingState.Success, state.loading) + Assert.assertEquals(mediaSource.id, state.currentMediaInfo.sourceId) + } + } + + private fun setupForPlaylist() { + coEvery { mockUserLibraryApi.getItem(playlist.id, any()) } returns + successResponse(playlist) + coEvery { + mockPlaylistCreator.createFrom( + item = playlist, + startIndex = any(), + sortAndDirection = any(), + shuffled = any(), + recursive = any(), + filter = any(), + ) + } returns PlaylistCreationResult.Success(playlistItems) + } + + @Test + fun `Play next up`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItem = slot<MediaItem>() + val mediaItem2 = slot<MediaItem>() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) + mockPlayer.setMediaItem(capture(mediaItem2), any<Long>()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Play previous`() = + runTest(testDispatcher) { + setupForPlaylist() + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + } + + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + } + + viewModel.playPrevious() + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + } + + val mediaItems = mutableListOf<MediaItem>() + verify(exactly = 4) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any<Long>()) + } + Assert.assertEquals(4, mediaItems.size) + Assert.assertEquals(movie.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[1].mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItems[2].mediaId) + Assert.assertEquals(movie2.id.toString(), mediaItems[3].mediaId) + } + + @OptIn(InternalCoroutinesApi::class) + @Test + fun `Show next up at end of playback`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(movie2, state.nextUp?.data) + } + } + + @Test + fun `Show next up never with automatic play`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = true + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertTrue(state.hasNext) + } + verify(exactly = 0) { mockNavigationManager.goBack() } + } + + @Test + fun `Show next up never without automatic play`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = false + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertTrue(state.hasNext) + } + verify(exactly = 1) { mockNavigationManager.goBack() } + } + + @Test + fun `Playback ends with no next up`() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + showNextUpWhen = ShowNextUpWhen.END_OF_PLAYBACK + autoPlayNext = false + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + viewModel.playNextUp() + viewModel.playNextUp() + testScheduler.advanceUntilIdle() + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertNull(state.nextUp) + Assert.assertFalse(state.hasNext) + } + verify(exactly = 1) { mockNavigationManager.goBack() } + } + + @Test + fun `Intro playback ends always play next`() = + runTest(testDispatcher) { + setupPreferences { + cinemaMode = true + showNextUpWhen = ShowNextUpWhen.NEXT_UP_NEVER + autoPlayNext = false + } + setupForPlaylist() + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockUserLibraryApi.getIntros(movie.id, any()) } returns + successQueryResult(listOf(intro)) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals( + listOf(PlaylistItem.Intro(BaseItem(intro))) + playlistItems.items, + state.playlist.items, + ) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.onPlaybackStateChanged(Player.STATE_ENDED) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(1, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + verify(exactly = 0) { mockNavigationManager.goBack() } + + val mediaItems = mutableListOf<MediaItem>() + verify(exactly = 2) { + mockPlayer.setMediaItem(withArg { mediaItems.add(it) }, any<Long>()) + } + Assert.assertEquals(2, mediaItems.size) + Assert.assertEquals(intro.id.toString(), mediaItems[0].mediaId) + Assert.assertEquals(movie.id.toString(), mediaItems[1].mediaId) + } + + @Test + fun playItemInPlaylist() = + runTest(testDispatcher) { + setupForPlaylist() + setupPreferences { + } + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertEquals(playlistItems.items, state.playlist.items) + Assert.assertEquals(0, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + + viewModel.playItemInPlaylist(BaseItem(movie3)) + testScheduler.advanceUntilIdle() + + viewModel.state.value.also { state -> + Assert.assertEquals(2, state.playlistIndex) + Assert.assertNull(state.nextUp) + } + val mediaItem = slot<MediaItem>() + val mediaItem2 = slot<MediaItem>() + verifyOrder { + mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) + mockPlayer.setMediaItem(capture(mediaItem2), any<Long>()) + } + Assert.assertEquals(movie.id.toString(), mediaItem.captured.mediaId) + Assert.assertEquals(movie3.id.toString(), mediaItem2.captured.mediaId) + } + + @Test + fun `Media segments auto skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1500.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and clicked`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + + viewModel.updateSegment(introSegmentMovie2.id, false) + every { mockPlayer.currentPosition } returns introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1 + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 1) { + // Verify only skipping a single time + mockPlayer.seekTo(introSegmentMovie2.endTicks.ticks.inWholeMilliseconds + 1) + } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ask to skip and dismissed`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.ASK_TO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == false) + } + + viewModel.updateSegment(introSegmentMovie2.id, true) + testScheduler.advanceTimeBy(10.milliseconds) + viewModel.state.value.also { state -> + Assert.assertEquals(introSegmentMovie2, state.currentSegment?.segment) + Assert.assertTrue(state.currentSegment?.interacted == true) + } + + // Never skipped + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment ignore`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.IGNORE + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 5.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } + + @Test + fun `Media segment not at current position`() = + runTest(testDispatcher, timeout = 8.seconds) { + setupForPlaylist() + setupPreferences { + skipIntros = SkipSegmentBehavior.AUTO_SKIP + } + + coEvery { mockMediaSegmentsApi.getItemSegments(movie.id) } returns + successResponse(MediaSegmentDtoQueryResult(emptyList(), 0, 0)) + coEvery { mockMediaSegmentsApi.getItemSegments(movie2.id) } returns + successResponse(segments) + + val viewModel = createViewModel(Destination.PlaybackList(playlist.id)) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + + every { mockPlayer.currentPosition } returns 2.5.seconds.inWholeMilliseconds + + try { + // TODO better testability + // This will start an infinite loop + viewModel.playNextUp() + testScheduler.advanceTimeBy(1000.milliseconds) + + viewModel.state.value.also { state -> + Assert.assertNull(state.currentSegment) + } + verify(exactly = 0) { mockPlayer.seekTo(any()) } + } finally { + viewModel.segmentJob?.cancel() + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt new file mode 100644 index 00000000..daeb934d --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt @@ -0,0 +1,21 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +fun WholphinDispatchers.configure(testDispatcher: TestDispatcher) { + Dispatchers.setMain(testDispatcher) + IO = testDispatcher + Default = testDispatcher +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun WholphinDispatchers.reset() { + Dispatchers.resetMain() + IO = Dispatchers.IO + Default = Dispatchers.Default +} diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json index f24aeefc..4a3c7666 100644 --- a/app/src/test/resources/embedded_subs.json +++ b/app/src/test/resources/embedded_subs.json @@ -165,7 +165,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -193,7 +194,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -221,7 +223,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json index 9f6a4b05..1ef202d4 100644 --- a/app/src/test/resources/external_subs.json +++ b/app/src/test/resources/external_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -79,7 +80,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -107,7 +109,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "eac3", @@ -194,7 +197,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json index be4e3051..d746913e 100644 --- a/app/src/test/resources/no_embedded_subs.json +++ b/app/src/test/resources/no_embedded_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "hevc", diff --git a/build.gradle.kts b/build.gradle.kts index 7e765b97..4ff28386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.hilt) apply false + alias(libs.plugins.android.library) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9c3cca9..dc7608cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,51 +1,59 @@ [versions] -aboutLibraries = "14.2.1" +aboutLibraries = "15.2.0" acra = "5.13.1" agp = "9.2.1" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" -hiltNavigationCompose = "1.3.0" -hiltWork = "1.3.0" +hiltNavigationCompose = "1.4.0" +hiltCompiler = "1.4.0" kache = "2.1.1" -kotlin = "2.3.21" -ksp = "2.3.8" -coreKtx = "1.18.0" -appcompat = "1.7.1" -composeBom = "2026.05.01" -mockk = "1.14.9" -robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.41.0" -okhttpBom = "5.3.2" +kotlin = "2.4.20" +ksp = "2.3.9" +coreKtx = "1.19.0" +appcompat = "1.8.0" +composeBom = "2026.09.00" +mockk = "1.14.11" +robolectric = "4.17" +multiplatformMarkdownRenderer = "0.45.0" +okhttpBom = "5.5.0" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" tvFoundation = "1.0.0" tvMaterial = "1.1.0-alpha01" -lifecycleRuntimeKtx = "2.10.0" +lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" -androidx-media3 = "1.10.1" -coil = "3.4.0" +androidx-media3 = "1.11.1" +coil = "3.6.2" jellyfin-sdk = "1.7.1" -nav3Core = "1.1.2" -lifecycleViewmodelNav3 = "2.10.0" -material3AdaptiveNav3 = "1.0.0-alpha03" +nav3Core = "1.1.7" +lifecycleViewmodelNav3 = "2.11.0" +material3AdaptiveNav3 = "1.3.0" protobuf = "0.10.0" datastore = "1.2.1" kotlinx-serialization = "1.11.0" -protobuf-javalite = "4.35.0" -hilt = "2.59.2" -room = "2.8.4" +protobuf-javalite = "4.36.1" +hilt = "2.60.1" +room = "2.8.5" preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" -assMedia = "0.4.0" +libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.22.0" +openapi-generator = "7.25.0" runner = "1.7.0" -wholphin-extensions = "0.1.3" +wholphin-extensions = "0.2.4" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +material = "1.14.0" + +compileSdk = "37" +targetSdk = "37" +minSdk = "23" [libraries] wholphin-extensions-mpv = { module = "com.github.damontecres.wholphin.mpv:wholphin-mpv", version.ref = "wholphin-extensions" } @@ -68,7 +76,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } -androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } @@ -77,9 +85,8 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", 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" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltCompiler" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } @@ -138,7 +145,11 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t 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" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "libass-android" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -151,3 +162,4 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } +android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad7..a9db1155 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index aa5f10b0..8508ef68 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel diff --git a/lint.xml b/lint.xml new file mode 100644 index 00000000..49e825b2 --- /dev/null +++ b/lint.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<lint> + <issue id="ExtraTranslation" severity="warning" /> +</lint> diff --git a/renovate.json b/renovate.json index bba12817..5f9021d9 100644 --- a/renovate.json +++ b/renovate.json @@ -11,7 +11,14 @@ }, { "groupName": "Github Actions", - "matchManagers": ["github-actions"] + "matchManagers": ["github-actions"], + "pinDigests": true + }, + { + "groupName": "Android TV Dependencies", + "matchPackageNames": [ + "androidx.tv:**" + ] }, { "groupName": "Androidx Media3", diff --git a/settings.gradle.kts b/settings.gradle.kts index a9195399..7fddcdcd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,3 +29,4 @@ dependencyResolutionManagement { rootProject.name = "Wholphin" include(":app") +include(":wholphin-mpv-stub") diff --git a/wholphin-mpv-stub/.gitignore b/wholphin-mpv-stub/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/wholphin-mpv-stub/.gitignore @@ -0,0 +1 @@ +/build diff --git a/wholphin-mpv-stub/README.md b/wholphin-mpv-stub/README.md new file mode 100644 index 00000000..67956101 --- /dev/null +++ b/wholphin-mpv-stub/README.md @@ -0,0 +1,5 @@ +# wholphin-mpv-stub + +This is a non-functional stub implementation for the `wholphin-mpv` library in https://github.com/damontecres/wholphin-extensions. + +This provides the minimal API so that Wholphin can compile without `wholphin-mpv`, but any runtime usage will error out. diff --git a/wholphin-mpv-stub/build.gradle.kts b/wholphin-mpv-stub/build.gradle.kts new file mode 100644 index 00000000..9017f416 --- /dev/null +++ b/wholphin-mpv-stub/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.android.library) +} + +private fun Provider<String>.getInt() = get().toInt() + +android { + namespace = "com.github.damontecres.wholphin.mpv" + compileSdk { + version = release(libs.versions.compileSdk.getInt()) + } + + defaultConfig { + minSdk = libs.versions.minSdk.getInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(libs.androidx.media3.exoplayer) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/wholphin-mpv-stub/consumer-rules.pro b/wholphin-mpv-stub/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/wholphin-mpv-stub/proguard-rules.pro b/wholphin-mpv-stub/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/wholphin-mpv-stub/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/wholphin-mpv-stub/src/main/AndroidManifest.xml b/wholphin-mpv-stub/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/wholphin-mpv-stub/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt new file mode 100644 index 00000000..d7c993f1 --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt @@ -0,0 +1,26 @@ +package com.github.damontecres.wholphin.mpv + +/** + * Stubs out MPVLib in wholphin-mpv + */ +object MPVLib { + fun setPropertyString( + key: String, + value: String, + ): Unit = throw MpvStubException() + + fun setPropertyInt( + key: String, + value: Int, + ): Unit = throw MpvStubException() + + fun setPropertyDouble( + key: String, + value: Double, + ): Unit = throw MpvStubException() + + fun setPropertyBoolean( + key: String, + value: Boolean, + ): Unit = throw MpvStubException() +} diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt new file mode 100644 index 00000000..fd4e0e8d --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.mpv + +import android.content.Context +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import kotlin.time.Duration + +/** + * Stubs out MpvPlayer in wholphin-mpv + */ +@OptIn(UnstableApi::class) +class MpvPlayer( + private val context: Context, + private val enableHardwareDecoding: Boolean, + private val useGpuNext: Boolean, +) : SimpleBasePlayer(Looper.getMainLooper()) { + override fun getState(): State = throw MpvStubException() + + var subtitleDelay: Duration = throw MpvStubException() +} diff --git a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt new file mode 100644 index 00000000..713d40c3 --- /dev/null +++ b/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt @@ -0,0 +1,3 @@ +package com.github.damontecres.wholphin.mpv + +class MpvStubException : Exception("This build is using wholphin-mpv-stub which does not implement actual functionality")